14 Commits
Author SHA1 Message Date
glm-5.3-flash 3855bb1c99 chore: bump to 0.6.0, changelog for the fuzzing adoption (alkcall 0.8.1) 2026-09-28 10:18:50 +00:00
glm-5.3-flash fb7cfe3851 feat(fuzz): session_opseq target - stateful op-sequence campaign against the session pump
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.
2026-09-28 10:06:00 +00:00
glm-5.3-flash e72e1fc4c7 fix(fuzz): commit only hand-made seeds - grown corpus entries swept in by the campaign window are excised; corpus/* ignore tightened to seed-* 2026-09-28 08:08:17 +00:00
glm-5.3-flash 49f70d9efb feat(fuzz): cargo-fuzz workspace, 3 wire-surface targets; bump alkcall 0.8.1
- Bump alkcall 0.8.0 -> 0.8.1 (duplicate adopt/open channel-state
  destruction fix from alkcall's fuzz campaign; AGENTS.md stale-pin fix).
- Adopt fuzzing per docs/plans/fuzzing.md (mirrors alkcall's
  docs/research/fuzzing.md as-built layout): fuzz/ workspace with own
  [workspace] table, nightly pinned for the subtree only, invariant
  logic in a stable-toolchain shared crate.
- Targets: chunk_frame (5-byte chunk codec), negotiation_frame
  (negotiation framing + NegotiateRequest + error_response_bytes +
  the cross-codec peek-disambiguation seam), control_json
  (ControlMessage JSON + signal_from_name). src/local/ out of scope
  (not wire-attacker-shaped).
- 328 committed seeds (deterministic generator); grown corpora
  gitignored. Corpus replay on stable is the standing fuzz gate
  (cargo test --manifest-path fuzz/shared/Cargo.toml).
- Detached-runner rule (fuzz/run-detached.sh): campaigns never run in
  the foreground of an agent session - OOM in a target must cost the
  fuzzer, never the session host.
- Root Cargo.toml gained [workspace] members/exclude (MSRV vs fuzz
  nightly dev-deps footgun) and fuzz/ in the publish exclude.

Verification: cargo test (113), cargo test --all-features (156),
clippy stable + wasm32-unknown-unknown, fmt, doc, publish dry-run,
corpus replay 328 seeds - all green. 10-min detached campaigns on all
three targets: 0 crashes/hangs/OOMs/leaks (chunk_frame cov-saturated
at 710 edges; negotiation_frame 3.69M execs cov 3157; control_json
8.6M execs cov 2035). Corpus replay caught three harness-model
mismatches pre-campaign (payload-slice shape; the negotiation framing
layer is length-prefix-only - Json unreachable from read_frame;
zero-length frames admitted by the reader, rejected by the adapter's
parse). Campaigns recorded in docs/plans/fuzzing.md section 8.
2026-09-28 08:07:30 +00:00
glm-5.3-flash e95459c425 feat: alkcall 0.8.0 adoption — bump to 0.5.0
- BREAKING (ecosystem-coordination): alkcall bumped to 0.8.0. No type
  alktty touches changed shape (ChannelCore/openable machinery,
  OpenEstablisher/OpenHandler/Establishment/ChannelPlan,
  ChannelClient::open_channel, ChannelOpenError::CallFailed are
  identical to 0.7.1); the new reply-projection / 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 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. The minor bump is for
  downstream lockstep, since alktty's public signatures reference
  alkcall types.
- Verification counts unchanged from the 0.7.1 baseline (113 default /
  156 all-features); the adoption required no source change in alktty.

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
2026-09-18 13:55:53 +00:00
glm-5.3-flash d14941ffb1 chore: bump to 0.4.1, changelog for the MSRV floor raise (1.85 -> 1.88)
Verification: 113 default / 156 all-features tests on stable (1.94) and
on a real 1.88 toolchain, clippy (all-targets) clean, fmt clean, doc
clean, wasm32 check + clippy clean, publish dry-run OK.
2026-09-10 02:55:11 +00:00
glm-5.3-flash 01e94914c1 chore: raise rust-version floor to 1.88
The 1.85 claim is now false at the dependency level: the lockfile pins
alkcall 0.7.x, and alkcall 0.7.1 raised its MSRV floor to 1.88, so no
1.85 toolchain can resolve this dependency graph regardless of what
Cargo.toml declares. Raising the floor keeps the claim honest and
aligns with the ecosystem floor (noq's 1.88, matching the QUIC path;
iroh sits above at 1.91). alktty's own code is 1.85-clean (verified:
cargo check under 1.85 passes with the pre-bump lockfile) — the raise
is dependency-driven only, so no clippy fixes are needed.

- Cargo.toml: rust-version 1.85 -> 1.88 (version-line bump deferred to
  release time)

Verification:
- 1.88: cargo test --locked (113 default / 156 all-features pass),
  clippy --all-targets -D warnings
- stable 1.94: test --locked, clippy --all-targets -D warnings,
  fmt, doc --no-deps, wasm32-unknown-unknown check + clippy clean
2026-09-10 02:53:33 +00:00
glm-5.3-flash 32809af840 docs: fill changelog version link refs (0.4.0/0.3.0/0.2.0) 2026-09-07 18:07:13 +00:00
glm-5.3-flash 67cf253621 feat: alkcall 0.7.0 adoption (CF-006 identity alignment) — bump to 0.4.0
- 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
2026-09-07 17:57:36 +00:00
glm-5.3-flash bc4a9b6024 docs: align remaining alloc-failure references with ADR-010 §2A (prepublish review for v0.3.0)
- README.md ADR index: ADR-010 status notes the §2A amendment
- ADR-009: amendment note records the §2A supersession (in-band path
  shrinks to nothing from registered producers)
- ADR-010 Consequences: dial_failed replaces the stale in-band
  allocate_failed retry-policy bullet
- AllocFailed doc comment + tty-backend.md: describe both failure
  surfaces (direct-path frame / channels-path dial_failed)
2026-09-07 10:04:44 +00:00
glm-5.3-flash af9dbaedea feat: allocation moves into the channels establisher (alkcall 0.6.0 / review 007 R-01) — bump to 0.3.0
- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049
  amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path
  allocation-failure shape)
- make_tty_establisher runs backend.allocate; failure maps to
  EstablishmentError::DialFailed (channel:open_failed reason
  dial_failed); the TtyHandle crosses to the pump handler via a
  private per-open AllocatedHandle one-shot slot (the handle is not
  Sync — it cannot be the ChannelPlan directly)
- make_tty_open_handler gains the Option<ChannelPlan> parameter; a
  plan drives the new drive_session_pre_allocated (pumps only), a
  None plan falls back to drive_session_pre_negotiated (inline
  validate-and-allocate — defense-in-depth for no-establisher
  registrations)
- tty_open_spec's channel:open_failed ErrorDefinition declares
  dial_failed (four reachable reasons)
- Pinned in-band allocate test flipped:
  allocate_failure_fails_open_as_dial_failed (end-to-end, no channel
  survives); establisher unit gate for the plan slot + failure
  mapping
- ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md,
  session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin
  updated

Verification: cargo test (113) + --all-features (137), clippy
-D warnings (native + wasm32, default + all-features), fmt, doc,
wasm32 check — all clean
2026-09-07 09:53:57 +00:00
glm-5.3-flash 3352a02dd5 docs: fix dangling fragment in tty-adapter.md ADR list (ADR-010 bullet edit leftover) 2026-09-06 20:46:12 +00:00
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
glm-5.3-flash 66c6e693bb docs: record v0.1.0 publish in review #003 (review closed) 2026-09-05 17:02:12 +00:00
373 changed files with 5754 additions and 183 deletions

No files matched your search

+22 -9
View File
@@ -194,10 +194,14 @@ implementation agents.
for the scope gate and optionally consults `OwnershipProvider` for
the resource-ownership check (`provider.owns(id_ref, kind, &id,
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
both for free via `ChannelCore::register_openable`, which wires
`AccessControl` into the operation spec — the registry runs the ACL
before the wrapper, so the `OpenHandler` only needs to validate
params and spawn the protocol. Terminal sessions are resources per
the scope gate for free via `ChannelCore::register_openable`
(which wires `AccessControl` into the operation spec — the
registry runs the ACL before the wrapper) and runs the ownership
check in the establisher (ADR-010 — it is part of the
establishment phase since alkcall 0.5.0 ADR-049, so a denial is
`channel:open_failed`, not an in-band frame; since alkcall 0.6.0
ADR-010 §2A the establisher also allocates — the pump handler
only pumps). Terminal sessions are resources per
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
dropped the old `action` arg).
@@ -234,6 +238,7 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo doc --no-deps
cargo test --all-features
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay — the standing fuzz gate
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo publish --dry-run --allow-dirty # before a release
@@ -244,6 +249,13 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
`cargo test --all-features` exercises the `local` backend (PTY mode is
`#[cfg(unix)]`; pipe mode is cross-platform).
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
seed through the same invariant functions the cargo-fuzz targets run —
on stable, no nightly needed. Run detached campaigns
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a
release and after touching `src/wire.rs`, `src/negotiation.rs`,
`src/control.rs`, the adapter, or the session pump.
## Architecture Context
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
@@ -308,8 +320,9 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
breaking changes are expected at this major-zero stage; this is the
first real consumer, so we find and fix issues upstream rather than
working around them. Pin `alkcall = "0.4.0"` (lockfile resolves
0.4.1) and bump deliberately.
`StreamError`) come from `alkcall::core`. alkcall is v0.8.x —
breaking changes are expected at this major-zero stage; this is the
first real consumer, so we find and fix issues upstream rather than
working around them. Pin `alkcall = "0.8.1"` (0.8.1 fixes the
duplicate adopt/open channel-state destruction found by alkcall's
fuzz campaign) and bump deliberately.
+276
View File
@@ -4,6 +4,276 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [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 —
@@ -135,4 +405,10 @@ alknet mono-repo.
the client→backend pump is aborted at session end instead of
lingering until the client disconnects.
[0.6.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.6.0
[0.5.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.5.0
[0.4.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.1
[0.4.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.0
[0.3.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.3.0
[0.2.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.2.0
[0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.4.1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9badefe048a194c93eed09bc5326ebf092fc86817e561d88de2cd12b6302753a"
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
dependencies = [
"async-trait",
"bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktty"
version = "0.1.0"
version = "0.6.0"
dependencies = [
"alkcall",
"async-trait",
+8 -4
View File
@@ -1,15 +1,19 @@
[package]
name = "alktty"
version = "0.1.0"
version = "0.6.0"
edition = "2021"
rust-version = "1.85"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
description = "Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels."
repository = "https://git.alk.dev/alkdev/alktty"
readme = "README.md"
keywords = ["tty", "terminal", "pty", "channels", "alkcall"]
categories = ["network-programming", "asynchronous"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md", "fuzz/"]
[workspace]
members = ["."]
exclude = ["fuzz"]
[lib]
name = "alktty"
@@ -25,7 +29,7 @@ default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
[dependencies]
alkcall = "0.4.0"
alkcall = "0.8.1"
# Minimal, wasm-clean tokio features. `local` adds `process` +
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
+4 -2
View File
@@ -19,7 +19,8 @@ Syntax Tree) document for the wire format, and the ADRs.
## Applicable ADRs
Ported from the alknet mono-repo and renumbered into alktty's ADR
range (001..009; ADR-009 is alktty-native). The alknet originals at
range (001..009; ADR-009 and ADR-010 are alktty-native). The alknet
originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
authoritative source for any ADR not yet ported, and for the alknet
ADRs referenced by alknet number in the docs below (which are not
@@ -35,7 +36,8 @@ tty-specific and therefore not ported into alktty's ADR range).
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame) |
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) |
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload — allocation moves into the establisher, no in-band frame from a registered producer) |
## Key Design Principles
@@ -11,6 +11,23 @@ of the schema-validated `input` is now a client-visible
`malformed_negotiation` error frame, not a silent teardown — see
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006
E-01), the semantic-failure classes the R4 amendment reported in-band
(malformed negotiation, unknown backend, ownership denial) move into
the producer's establisher and resolve as `channel:open_failed` call
errors — no phantom channel. The in-band error-frame path on this
channel shrinks to one class: `allocate_failed` (the establisher
cannot carry the allocated handle across — see ADR-010 §2). The R4
tests that asserted the in-band frames for those classes now assert
the call-error shape.
Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0 — review 007 R-01):
`Establishment` gained its plan payload, so `allocate` also moved
into the establisher — the in-band error-frame path on this channel
shrinks to nothing from a registered producer (the frames remain the
defense-in-depth fallback for no-establisher registrations and the
direct path). See ADR-010 §2A.
## Context
Before this ADR, the channels path carried the negotiation twice. The
@@ -0,0 +1,299 @@
# ADR-010: Channels-Path Establishment Failures Move into an Establisher
## Status
Accepted (2026-09-06). Migrates the channels-path semantic-failure
surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the
sequencing ADR-049 §5 names ("alktty migration — channels-path
semantic failures move into an establisher; the direct-ALPN in-band
error frame is retained — two transports, two contracts").
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
`channel:open_failed`, typed `ChannelOpenError`).
**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2 — review
007 R-01):** `Establishment` gained its plan payload
(`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's
payloadless-`Establishment` blocker. Backend `allocate` moves into
the establisher; the allocated `TtyHandle` crosses to the pump
handler via the plan; `allocate_failed` joins the call-error surface
as `details.reason == "dial_failed"` (the target refused or the
backend lacked capacity). No failure class arrives in-band on the
channels path from a registered producer; the in-band error-frame
fallback stays for no-establisher registrations (defense-in-depth)
and the direct-ALPN path. See §2A below; §2 is retained as
historical record.
Amends ADR-009's R4 amendment: the in-band error-frame path on the
channels path shrinks to one failure class (`allocate_failed`).
## Context
ADR-009 made the open op's `input` the negotiation and moved semantic
validation into the producer-side handler. Because alkcall 0.4.x's
open wrapper could not fail after allocation (review 006 E-01), the
semantic-failure classes had nowhere to go but the channel stream:
the handler wrote a `0x00`-prefixed negotiation error frame
(`malformed_negotiation`, `unknown_backend`, `allocate_failed`,
ownership-denial `forbidden`) on a channel the open op had already
reported as succeeding. The consumer observed success-then-frame —
a per-crate workaround ADR-049 explicitly retires:
> alktty's per-crate in-band error vocabulary is retired on the
> channels path; every future ALPN crate gets the establishment reply
> for free.
alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded
establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture<
Result<Establishment, EstablishmentError>>`) between channel
allocation and the open reply; on rejection it tears the channel down
and replies `channel:open_failed` with `details: { reason, message }`
(reason ∈ `dial_failed` / `unknown_resource` / `resource_shortage` /
`handler_error` / `timeout`). The SSH contract holds
consumer-visibly: a failed open never returns a `channel_id`.
`ChannelClient::open_channel` carries the `CallError` verbatim in a
typed `ChannelOpenError` (review 006 N-1), so the reason is
branchable end-to-end.
## Decision
### 1. The channels path registers an establisher
`register_openable` (alktty's helper, signature unchanged) now calls
`ChannelCore::register_openable_with_establisher` with
`make_tty_establisher` — the establishment phase runs the semantic
validation that used to be post-open error frames:
- Full `NegotiateRequest` parse of the registry-schema-validated
`input` (the schema stays deliberately partial — the opaque ADR-053
backend params pass through; the establisher is the typed gate).
- `carriage == "raw"`, non-empty `cmd`.
- Backend lookup against the adapter's backend map.
- The ADR-050 ownership check (moved from the pump handler's
`validate_and_allocate`; the scope gate stays the registry's —
pre-allocation, unchanged).
Rejections map into ADR-049's fixed reason vocabulary (no new codes —
the vocabulary is alkcall's one-way wire surface):
| TTY failure class | `EstablishmentError` variant | `details.reason` |
|---|---|---|
| `NegotiateRequest` parse failure | `HandlerError` | `handler_error` |
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
| unknown backend | `UnknownResource` | `unknown_resource` |
| ownership denial | `HandlerError` | `handler_error` |
| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` |
| establishment deadline | (wrapper) | `timeout` |
`resource_shortage` has no TTY producer path (allocation-capacity
failures map to `dial_failed` — the target refused or the backend
lacked capacity; §2A). `HandlerError` covers both the
malformed-negotiation semantics (the request shape is wrong) and the
ACL-outcome ownership denial (an authorization failure, not a dial
failure).
### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band
> **Amended away (2026-09-07) — see §2A.** This section is the
> historical record of the pre-0.3.0 shape; its blocker 1
> (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049
> amendment 2, review 007 R-01). Blocker 2 (double-`allocate`
> unsoundness) is avoided by construction in the amended shape: the
> establisher's handle *is* the handle the handler pumps — there is no
> second allocate to be unsound about.
The one failure class that cannot migrate: `backend.allocate`. Two
reasons, both structural:
1. **`Establishment` is payloadless** ("reserved for a channel plan").
There is no sound way to hand the allocated `TtyHandle` from the
establisher to the pump handler: the establisher and handler are
separate closures registered once per connection (no per-open key
is shared between them), and an `Arc<Mutex<Option<TtyHandle>>>`
stash would need exactly that key to be race-free.
2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`**
contract: re-allocating in the handler after an establisher-side
allocate would spawn the session target twice (the first handle's
kill-guard would fire on its drop — a session killed before it
started), and leaking the establisher-side handle would leak the
live target.
So `backend.allocate` runs where it always did — inside
`drive_session_pre_negotiated`'s `validate_and_allocate` — and
`allocate_failed` remains a post-open `0x00`-prefixed negotiation
error frame, surfaced consumer-side as
[`TtySessionError::NegotiationRejected`] via the same peek the direct
path uses. A pinned test (`allocate_failure_still_arrives_in_band_on_
channels_path`) guards this boundary. Revisit when alkcall gives
`Establishment` a payload (a channel plan carrying the handle would
let `allocate_failed` join the call-error surface).
The pump handler's parse arm (`malformed_negotiation` frame) and
`validate_and_allocate`'s other error frames are now defense-in-depth:
the establisher rejects those classes first, so on the registered
path only `allocate_failed` is reachable. The arms stay (a
no-establisher registration still compiles against alkcall's API and
must not silently EOF), and the R4 seam test pins the handler-side
frame.
### 2A. Amendment (alkcall 0.6.0): allocation moves into the establisher
alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the
reserved field: `Establishment { plan: Option<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, wrapper
threaded to the `OpenHandler`'s new second parameter. §2's blocker 1
is gone; the double-`allocate` hazard (blocker 2) is structurally
absent — one allocate per open, whose result the handler consumes.
The amended shape:
1. **The establisher allocates.** After the validation sequence (§1),
it runs `backend.allocate(&params)`. Failure maps to
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`
— the target refused or the backend lacked capacity; one string
message, no nuance — the same granularity the direct path's
`allocate_failed` frame always had). The
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
therefore declares four reachable reasons (`dial_failed`,
`unknown_resource`, `handler_error`, `timeout`).
2. **The handle crosses via the plan.** `TtyHandle` is not `Sync`
(boxed dyn streams), so it cannot be the `ChannelPlan` directly.
The establisher wraps it in a private `AllocatedHandle` one-shot
slot (`Mutex<Option<TtyHandle>>`), created **per establisher
invocation** — per open. The wrapper moves this open's plan to this
open's handler (alkcall's concurrent-same-resource test pins that
plans are never shared), the handler `take()`s it exactly once, and
establisher-before-handler ordering is alkcall's wrapper guarantee
(ADR-049 §1). The shared-slot race §2 described is unreachable by
construction: there is no shared slot.
3. **The handler pumps the pre-allocated handle.** With a plan it
calls `drive_session_pre_allocated` (new `pub(crate)` driver: the
pumps directly, no validation pass). With `plan: None` — a
no-establisher registration — it falls back to
`drive_session_pre_negotiated`, whose inline validate-and-allocate
keeps the full in-band error-frame vocabulary. That fallback is
defense-in-depth for the assembly-layer freedom to register without
an establisher, not a registered-producer path.
4. **Consumer surface.** `TtySession::open_via_channels` sees
allocation failure as
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })` — a typed call error, branchable and retryable
like every other establishment class. The `0x00` peek on the
channel stream becomes a formality that no registered producer
ever exercises; the pinned test flipped with the contract
(`allocate_failure_fails_open_as_dial_failed` in `testing.rs`,
plus the establisher unit gate `establisher_allocates_and_maps_
failure_to_dial_failed` in `channels.rs`).
The R-02 lifetime contract (the handler's `JoinHandle` tracks the
data-plane lifetime) was already alktty's shape — the open handler
awaits the driver inline in its spawned task — so the amendment adds
no lifetime changes.
### 3. The direct-ALPN path is unchanged
`TtyAdapter::handle` / `drive_session` keep the wire-frame
negotiation (ADR-001) and the full in-band error-frame vocabulary
(`unknown_backend`, `malformed_negotiation`, `allocate_failed`,
`forbidden`). Two transports, two contracts — the direct path has no
open op to fail, and its `0x00`-peek disambiguation is the ADR-001
wire-stable contract. The `enforce_scope` split from ADR-009 is
unchanged (`true` direct, `false` channels — the registry's
`AccessControl` is the channels scope gate).
### 4. Spec enrichment (review 006 E-02 + ADR-016)
`tty_open_spec()` gains:
- `description` — disclosed by `services/list` (the open op describes
itself; the produced resource set stays OQ-40's deferred
`channel/resources/subscribe` shape).
- An `ErrorDefinition` for `channel:open_failed` with the
`details.reason` enum — so `services/schema` discloses the
establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN
crates' open-op specs gain matching `ErrorDefinition` entries so
`services/schema` discloses the failure contract").
### 5. Consumer surface
`TtySession::open_via_channels` failures after this ADR:
| Failure | Where | Consumer-visible as |
|---|---|---|
| schema-invalid params | registry gate | `ChannelsOpen(CallFailed{ INVALID_INPUT })` |
| unparseable params (local fail-fast) | pre-open local parse | `InvalidParams` |
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
`TtySessionError::ChannelsOpen` now carries alkcall's typed
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened
`String` — the N-1 fix applied at alktty's layer, so consumers branch
on `establishment_reason()` without unwrapping strings.
## Consequences
**Positive:**
- The phantom-channel workaround is retired: a semantically invalid
open no longer allocates → succeeds → in-band-fails. Ledger, policy
count, and manager state balance on every rejection.
- Retry policy / UX can branch: `unknown_resource` (bad config — don't
retry), `timeout` (maybe retry), `dial_failed` (capacity/target
refused — maybe retry; amended §2A, was the in-band
`allocate_failed`).
- The consumer's `open_via_channels` failure is a typed call error —
no peeking at the data stream for anything but the (now
unreachable-from-registered-producers) error-frame formality.
**Negative:**
- **Breaking (0.2.0):** channels-path semantic failures change shape
(`NegotiationRejected{unknown_backend}` → `ChannelsOpen(CallFailed{
channel:open_failed})`), and `ChannelsOpen`'s payload changed. The
direct path's failure surface is unchanged.
- The `channel:open_failed` reason codes the establisher emits join
the wire-stable error set (ADR-049's one-way door).
- **Amendment (0.3.0):** allocation failure changed shape again —
`NegotiationRejected{ "allocate_failed" }` (in-band) →
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })`. The direct path's `allocate_failed` frame is
unchanged (two transports, two contracts).
## Door type
**One-way (wire-visible).** The establisher mapping fixes
`details.reason` values for TTY's channels path — consumers branch on
`unknown_resource` / `handler_error` / `timeout`, so re-mapping a
failure class between reasons is a peer-breaking change. The
establisher's internal validation order is a two-way door
(implementation detail behind the fixed surface). ADR-009's
"two-way for now" negotiation-location decision is unaffected — this
ADR moves failure *reporting*, not the negotiation *location*.
## References
- alkcall ADR-049 (the establisher, `channel:open_failed`, the typed
client error — the upstream capability this ADR adopts)
- alkcall review 006 (E-01 the establishment gap; N-1 the typed
client error; E-02 the discovery enrichment this ADR's spec
enrichment adopts)
- ADR-009 (the open op's `input` is the negotiation; the R4
amendment's in-band error frames — shrunk to `allocate_failed` by
this ADR)
- ADR-005 (kill-on-`Drop` — why a *second* establisher-side allocate
would be unsound; avoided by construction in the §2A amendment —
one allocate per open, consumed by the handler)
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct
path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership)
- alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
plan payload — the upstream capability this ADR's §2A adopts)
- `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`,
`make_tty_open_handler`, `tty_open_spec`
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels`
- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed`
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
+27 -9
View File
@@ -65,11 +65,13 @@ channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multi
TTY always uses its 5-byte format, so the same driver runs in
both direct `alk/tty` and channels `alk/channels` modes; only the
`BiStream` source differs. The two paths differ in where the
`NegotiateRequest` comes from: the direct path reads the wire-frame
negotiation (ADR-001 §"Negotiation Frame"); the channels path parses
the open op's registry-validated `input` and runs
`drive_session_pre_negotiated` (ADR-009 — the channels path carries no
second negotiation frame on the channel's data stream).
`NegotiateRequest` comes from and where semantic failures go: the
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
Frame") and answers failures with in-band error frames; the channels
path parses the open op's registry-validated `input` and runs
`drive_session_pre_negotiated` (ADR-009 — no second negotiation
frame), with the establisher rejecting the semantic-failure classes
as `channel:open_failed` before the handler spawns (ADR-010).
## Why
@@ -172,11 +174,21 @@ entering raw mode. The error response shape:
{ "error": "unknown_backend", "backend": "kubernetes" }
```
On the **direct-ALPN path** this is the failure surface for every
negotiation/allocation failure class (below). On the **channels path**
the semantic classes (unknown backend, malformed negotiation,
ownership denial) **and allocation** are rejected by the establisher
before the open reply and surface as `channel:open_failed` call
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
`Establishment` plan payload — ADR-010 §2A) — the in-band frames on
that path are defense-in-depth arms only (reachable from
no-establisher registrations).
| Error | When | Shape |
|-------|------|------|
| `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` |
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame, on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial) | `{"error":"malformed_negotiation","message":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` |
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — the failure surface of the **direct path**. On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload — ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` |
After sending the error response, the adapter closes the write half of
the bidi stream. The client reads the error frame and treats stream close
@@ -358,6 +370,7 @@ architectural commitment.
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures and allocation are rejected/run in the establisher (`channel:open_failed`, alkcall ADR-049; alkcall 0.6 plan payload carries the `TtyHandle`); no in-band frame from a registered producer |
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
## Open Questions
@@ -379,7 +392,12 @@ architectural commitment.
the same session driver runs in channels mode
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the
channels path carries no second negotiation frame (the open op's
`input` is the negotiation)
registry-validated `input` is the negotiation)
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
channels path's semantic failures are establisher call errors
(`channel:open_failed`); since alkcall 0.6 the establisher also
allocates (the `TtyHandle` crosses via the plan payload), so no
failure class arrives in-band from a registered producer
- alknet ADR-050 — the ownership model the adapter's access control
declares against
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
+4 -2
View File
@@ -125,8 +125,10 @@ pub enum TtyError {
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed
to start, the SSH channel request was rejected. Returned by
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and
closes (tty-adapter.md §"Negotiation errors").
`allocate()`; on the direct path the adapter sends
`{"error":"allocate_failed",...}` and closes (tty-adapter.md
§"Negotiation errors"); on the channels path the establisher maps it
to `channel:open_failed` / `dial_failed` (ADR-010 §2A).
- `WaitFailed` — the backend couldn't reap the child / determine the
exit code. Returned by the `exit_code` future; the adapter sends
`{"type":"exit","code":-1}` (ADR-004 §4).
+350
View File
@@ -0,0 +1,350 @@
# Fuzzing alktty: Plan
**Status:** adopted — implementation in progress (see §8 progress log)
**Date:** 2026-09-28
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
runner, and no-CI policy below are adopted from it wholesale rather than
re-derived).
---
## 1. Decision: fuzz this crate
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
"Non-surfaces" explicitly defers the downstream parsers to "downstream
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
owns four parse surfaces fed by arbitrary peers on both transports (direct
ALPN and channels):
| # | Surface | Location | Shape |
|---|---|---|---|
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
Reasons this pays off here, beyond the generic alkcall argument:
1. **Semantic invariants no example test explores adversarially.** The
peek/disambiguation machinery (`peek_stream_type` vs
`read_chunk_after_peek` — both call orders must yield exactly one
chunk) and the cross-codec invariant that makes it sound: an
error-response frame's length-prefix high byte must be `0x00` so a
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If
`MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant
breaks subtly. A fuzz target spanning both codecs is the cheapest
enforcement that exists.
2. **The alkcall bug class has a plausible analog here.** The duplicate
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
unit test replays destroyed the live channel. Alktty's adapter has the
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
session re-registration, handle replacement. A stateful op-sequence
target against the session pump + mock backend is where a real finding
would live.
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
as-built layout is a port, not a design exercise (§5 below).
Zero parser-bug yield is a successful negative result — it converts "we
think the codecs are robust" into a demonstrated property (the quinn
lesson cuts both ways).
**Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not
wire-attacker-shaped. The wire-facing side is entirely the
shared/producer/consumer modules (`wire`, `negotiation`, `control`,
`backend`, `adapter`, `channels`, `session`).
**The wasm invariant is unaffected:** `fuzz/` is host-only (nightly
toolchain, `path` dep on the crate), excluded from publishing, and never
compiled for `wasm32-unknown-unknown`.
## 2. Toolchain decision (adopted from alkcall §4/§7.7)
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
`[workspace]` table (nightly-only bins).
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain
library crate** — not inline in the target binaries. The `fuzz_target!`
binaries are thin wrappers. This makes corpus replay a plain
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
replays every committed seed through the identical invariant functions
the fuzzer runs, and joins the release verification checklist.
- `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the
crate stays stable at MSRV 1.88.
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
deterministic seed generator script committed next to them.
- **No hosted CI** — this repo has the same no-CI policy as alkcall
(§7.9 there): the gitea host serves git only; verification and
campaigns are manual. Corpus replay is the standing fuzz gate;
campaigns run via the detached runner before releases and after
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
adapter, or the session pump. Do not add workflow files or "when CI
exists" language.
- AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4.
## 3. Operational isolation: campaigns must be detached from the agent session
**This is mandatory, not a nice-to-have.** Agent sessions (opencode) run
commands through a bash tool that spawns processes as children of the
session's own process tree and cgroup. A fuzz campaign is exactly the
workload shape that can kill its own host:
- **OOM-killer shared fate.** The target's worst case *is* unbounded
allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge
allocation can beat it, and then the kernel OOM killer fires. It picks
the largest-RSS process in the cgroup, which can be the opencode server
hosting the session. Losing that server is recoverable (sessions are
recorded in a db) but hugely derailing and entirely avoidable. An OOM
in a fuzz target must cost the fuzzer, never the session.
- **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task
spawning in a buggy target can starve the session host.
- **Deadlock hangs:** a wedged target must not hang the session's bash
call.
Three layers, all standard:
1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048`
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
with ASAN (the sanitizer reserves terabytes of virtual address space;
the classic failure is instant `MmapAlloc` death) — the ASAN-safe
equivalent is `malloc_limit_mb`.
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
each input in a short-lived child; a crash/timeout/OOM/leak kills only
that child, the parent records the artifact and keeps going. Also
turns deadlocks into per-input timeouts instead of hung sessions.
3. **Process detachment (mandatory for agent-run campaigns):** never run
fuzzing as a foreground child of the session. `fuzz/run-detached.sh`
wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log
file: removed from the session's controlling terminal and signal
relation, survives the session ending, and gives a pollable log
instead of a blocking call.
Polling protocol (never `wait` on the detached process):
```bash
tail -n 50 fuzz/artifacts/<target>-*.log # campaign progress
ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts
pgrep -f "cargo fuzz run <target>" # still running?
```
One session holds one detached campaign per target; log filenames carry
UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget
(default 600 s).
## 4. Targets
In priority order. Targets 1–3 are the initial scope; target 4 is second
wave, decided after campaigns on 1–3 come back clean (same sequencing as
alkcall §7.4).
| # | Target | Input style | Drives | Invariants |
|---|---|---|---|---|
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
The consumer disambiguates an error-response frame from a raw chunk by
peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4};
a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
neither. Encoded as fuzz assertions:
- For every `error_response_bytes` output and every negotiation frame of
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader`
peek on `[frame bytes…]` never mistakes it for a chunk.
- **Known boundary collision, encoded deliberately:** a frame of length
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` —
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
inherent edge of the one-byte-peek design; producers only ever send
tiny error frames, so it is accepted — but the assertion pins the
boundary so any future `MAX_CHUNK_LEN` change that widens the
collision gets caught. (`validate_header`'s doc comment at
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
claim holds only for `<` the cap. Worth a one-word fix in the doc
comment if we touch `wire.rs` for any other reason.)
Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid
negotiation frame with one extra JSON byte appended *and counted in the
length prefix* must fail with a `Json` error, so a future switch to a
tolerant streaming reader is caught.
## 5. Layout (mirrors alkcall §7.7 as-built)
```
fuzz/
├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/
│ ├── chunk_frame.rs thin fuzz_target! wrapper
│ ├── negotiation_frame.rs thin wrapper
│ ├── control_json.rs thin wrapper
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
├── corpus/<target>/ committed seeds
├── gen_fuzz_seeds.py deterministic seed generator
├── run-detached.sh §3 detached runner (CWD-independent)
└── README.md
```
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without
it cargo auto-discovers `fuzz/shared/` into the main workspace and the
MSRV toolchain tries to build nightly-consumed dev-deps.
- `"fuzz/"` added to the publish `exclude` list.
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
(alkcall needed none of its five either). The one private constant,
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
own copy, and the shape invariants assert the rejection boundary so a
drift is caught. The `session_opseq` harness is fuzz-local for the same
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
backend in `src/` would either leak into the public API or force the
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
harness also lives in `fuzz/shared/`).
## 6. Corpus policy
Commit hand-made seeds; gitignore grown corpora and artifacts
(`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers
for every stream type / every control variant, truncation at every prefix
length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length =
u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave
fixtures, cross-codec error-frame fixtures. The generator script is
deterministic (no randomness) so seeds are reproducible.
## 7. Sequencing
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
alktty's own path — `TtySession::open_via_channels` and the channels
establisher run on the fixed `ChannelManager`). Full verification set
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
line while touching it. **Status: see §8.**
2. Scaffold `fuzz/` + root workspace table + publish exclude.
3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins +
seeds + detached runner + README.
4. Corpus replay green on stable (`cargo test --manifest-path
fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification
checklist.
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
anything found, record results in §8.
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
**Status: landed — see §8. All four targets are in scope and live.**
## 8. Progress log (append-only across sessions)
- **2026-09-28** — Proposal discussed and adopted; this plan written.
Alkcall dependency bump + fuzz scaffold started.
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.**
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
all-features 156 tests, clippy stable/wasm, fmt, doc).
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
replay added to the verification checklist; campaign guidance
added.
- `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish
exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins,
invariant logic in `fuzz/shared/` (stable toolchain), 328 committed
seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README,
`.gitignore`.
- Corpus replay green on stable
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
- Harness-model corrections the corpus replay caught *before* any
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
chunk with trailing stream bytes is correct reader behavior — the
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing
layer is length-prefix-only — `read_frame` returns raw bytes and the
adapter does the serde parse (`adapter.rs:411`), so
`NegotiationError::Json` is unreachable from this reader (unlike
alkcall's serde-backed envelope reader; the `Json` arm now panics as
"unreachable" and the trailing-byte probe asserts exact-body
consumption instead of a Json error); (3) zero-length negotiation
frames are admitted by this reader (the adapter's parse rejects
them) — the shape assertion was relaxed accordingly.
- **10-min detached campaigns, all three targets: every run exited 0
with an empty artifact directory — no crashes, hangs, OOMs, or
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
seconds — the 5-byte-parser ceiling is fully enumerated);
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
budget end — JSON structure exploration); `control_json` 8.6M execs
(cov 2035, still growing). Grown corpus entries were deleted per the
§6 policy; committed seeds unchanged.
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
session.
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
`session_opseq`. All four targets are now live.**
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
barrier probes, backend production/EOF, exit resolve/fail/drop,
control messages, bounded reads, yields) + a fuzz-local
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
the harness driving `drive_session_pre_negotiated` over a
`tokio::io::duplex` pair, with a delivery model mirroring §4's
invariants. 12 committed seeds via the deterministic generator
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
gating each `Vec` element; the encoding is pinned by a decode test
asserting the exact ops each seed decodes to).
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
prefix-losslessness (post-shutdown chunks never delivered), control
dispatch bounds, take-once allocation, teardown termination.
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
unbounded backend stdin (control dispatch stays prompt).
- **Harness-model correction before the campaign** (the alkcall §7.7
pattern repeating, third time): a 60s smoke run crashed on
`ClientWrite` after the session had completed — a failed client
write can mean the *server half of the duplex dropped* because the
session finished (exit resolved → pumps join → exit chunk →
drainer exits → duplex halves drop) while the write was in flight.
The harness now treats any write/shutdown error as "the session
closed": it drains the read side and requires the exit chunk (a
close without it is a premature close — a real bug). No adapter
code changed; the finding was a harness-model gap.
- Corpus replay green on stable (9 tests, incl. the seed-decode
pin). Full checklist green: 113 + 156 all-features tests, clippy
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
check.
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
job (the op-seq target is stateful — each input runs a full tokio
session with bounded reads/yields, so exec/s is inherently tens, not
millions; the value is interleaving exploration). Cov grew
3934 → 4075 edges over the run, still finding coverage at budget
end. Grown corpus excised per the §6 policy; 12 committed seeds
unchanged; corpus replay green after.
- §7 fuzzing plan complete: all four targets live, corpus replay is
the standing gate, campaigns pre-release and on adapter/pump/wire
changes.
## 9. Relationship to existing tests
The existing suite covers valid-input round-trips and documented error
paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs`
frame tests, `control.rs` message tests). Fuzzing adds: random malformed
input exploration (the quinn CVE class), stateful interleavings (target
4), and adversarial exploration of the peek/disambiguation seam that
example-based tests structurally cannot enumerate.
Verification additions once landed:
```bash
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate
```
…added to the AGENTS.md checklist alongside the existing commands. The
wasm checks (`cargo check --target wasm32-unknown-unknown`,
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain
untouched by fuzz work but must still pass after the root `Cargo.toml`
gains its `[workspace]` table.
+9 -3
View File
@@ -2,6 +2,7 @@
status: closed
last_updated: 2026-09-05
resolved: 1-16 (P14 closed; P13's accept-loop harness deferred by disposition)
published: v0.1.0 (crates.io, 2026-09-05) — review closed before publish
reviewed_artifacts:
- src/lib.rs
- src/wire.rs
@@ -703,9 +704,14 @@ Verification on the resolution tree: `cargo test` 104 lib (unchanged) /
(0 warnings), `cargo publish --dry-run --allow-dirty` (42 files,
659.2KiB) — all clean.
**Review #003 is closed.** Remaining before publish: tag `v0.1.0` (the
CHANGELOG link definition points at it) and `cargo publish` (dry-run
clean).
**Review #003 is closed.** Published: `cargo publish` uploaded alktty
v0.1.0 to crates.io (2026-09-05, dry-run gate clean without
`--allow-dirty`), and the `v0.1.0` tag — the CHANGELOG link
definition's target — is pushed. Post-publish iteration is expected at
first-consumer time (the alkcall trajectory: unpublished mistakes were
free, per-consumer ones are semver cost); the wire format (ADR-001)
and the `TtyBackend` trait shape (ADR-002) are the one-way doors this
review scrutinized hardest.
## Notes
+7
View File
@@ -0,0 +1,7 @@
target
corpus/*
!corpus/*/
corpus/*/*
!corpus/*/seed-*
artifacts
coverage
+1330
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "alktty-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alktty-fuzz-shared = { path = "shared" }
[dependencies.alktty]
path = ".."
[[bin]]
name = "chunk_frame"
path = "fuzz_targets/chunk_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "negotiation_frame"
path = "fuzz_targets/negotiation_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "control_json"
path = "fuzz_targets/control_json.rs"
test = false
doc = false
bench = false
[[bin]]
name = "session_opseq"
path = "fuzz_targets/session_opseq.rs"
test = false
doc = false
bench = false
[workspace]
+73
View File
@@ -0,0 +1,73 @@
# alktty fuzzing
cargo-fuzz targets for the wire-facing parse surfaces. The design and
operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
`docs/research/fuzzing.md`); this README is the operational cheat-sheet.
## Layout
- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers).
- `shared/` — stable-toolchain library holding the invariant logic; the
corpus replay tests run here on plain `cargo test`.
- `corpus/<target>/` — committed seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py`).
- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs.
## Targets
| Target | Drives |
|---|---|
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
## Running a campaign — always detached
Agent sessions must never run fuzzing in the foreground (an OOM in a
target can take down the session host; see docs/plans/fuzzing.md §3).
Use the detached runner:
```bash
fuzz/run-detached.sh chunk_frame
# poll:
tail -n 50 fuzz/artifacts/chunk_frame-*.log
ls fuzz/artifacts/chunk_frame/
pgrep -f "cargo fuzz run chunk_frame"
```
`FUZZ_RUNTIME_SECS=1800 fuzz/run-detached.sh negotiation_frame` for a
longer campaign. The runner pins `-fork=1 -rss_limit_mb=2048
-malloc_limit_mb=2048 -timeout=25` and detaches via `setsid` + `nohup`.
## Corpus replay (the standing fuzz gate)
```bash
cargo test --manifest-path fuzz/shared/Cargo.toml
```
Replays every committed seed through the same invariant functions the
fuzzer runs, on stable, no nightly needed. Part of the release
verification checklist (AGENTS.md).
## Toolchain notes
`rust-toolchain.toml` pins nightly for this subtree only (llvm-tools
required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
`cargo fuzz build` must be run with the fuzz dir as CWD or trust the
toolchain file (rustup resolves per directory).
Run campaigns before releases and after touching `src/wire.rs`,
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
## session_opseq notes
The op-sequence target is inherently slower than the byte-format targets
(each input drives a full tokio session with bounded reads and yields —
tens of exec/s per worker, not millions). That is expected: the value is
state-space exploration, not throughput. It uses `tokio::time::timeout`
internally, so the nightly target needs the `--features time` tokio
surface already present via the shared crate's deps — no extra flags.
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
semantics) are the ones unit tests assert individually; the fuzzer
explores interleavings no example test can enumerate.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff. Show more