Closes review #003 (prepublish review for v0.1.0). - P8: StdinSink::poll_shutdown parks an inflight reserve+send on a full channel (waker registered) — a stdin blast followed by EOF delivers the EOF instead of stranding it - P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner) - P10: three thread-spawn .expect() sites -> TtyError::AllocFailed - P5: tty:open scope gate runs before carriage/cmd/backend-lookup checks (no backend-name enumeration differential for unscoped ids) - P11: recv_stdout terminates on the zero-length drained sentinel; the sentinel is no longer yielded as an item (doc was already the contract); stderr has no sentinel (doc noted) - P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and docs/research/ entries (package list: 42 files, 659.2KiB) - P3: AGENTS.md phase status (all five landed), ADR range 001..009 (+ alktty-native ADR-009 in the mapping), alkcall guidance corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row + landed-phase status - P15: backend.rs doc typo; redundant tokio-stream dev-dep removed; NegotiationError::Io arm logs; set_identity failure logs; input_pump.abort() at session end; TtySessionError::Open carries the accept_bi StreamError (no io::Error flattening); borrowing deserialize in open_via_channels (no params.clone()); error_response_bytes guards an "error" key in fields; trivial inline comments promoted/removed; plan-doc test counts + doc front-matter refreshed; session tests that raced session teardown under the abort change use a GatedBackend (exit held until released) Verification: cargo test 104 lib / --all-features 147; clippy (all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings; publish dry-run OK.
673 lines
33 KiB
Markdown
673 lines
33 KiB
Markdown
# Project Setup Plan
|
||
|
||
Status: draft (revised 2026-08-17 to reflect landed upstream changes;
|
||
Phase 4 landed 2026-08-17 — architecture docs + BAST schema +
|
||
renumbered ADRs; Phase 5 landed 2026-08-17 — tests)
|
||
Last updated: 2026-08-17
|
||
|
||
## Overview
|
||
|
||
Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a
|
||
standalone `alktty` crate — a producer/consumer protocol crate on top of
|
||
alkcall channels. This is the first protocol crate built on alkcall and
|
||
establishes patterns that `alktunnels` and others will follow.
|
||
|
||
The local backend (`alknet-tty-local`) is folded into `alktty` as a
|
||
feature-gated `local` submodule rather than kept as a sibling crate.
|
||
Both the original `alknet-tty` (the wire/adapter/backend-trait half) and
|
||
`alknet-tty-local` (the `portable_pty` + `tokio::process` half) are
|
||
ported together, so the single crate carries the full stack: the
|
||
`alk/tty` protocol + the one backend implementation that ships with it.
|
||
A docker or SSH backend would be a separate crate; the local backend is
|
||
small and the cyclic-dependency workaround it required in the alknet
|
||
mono-repo (see ADR-054) doesn't apply to a single crate with a feature
|
||
flag.
|
||
|
||
## Upstream changes that landed since the first draft
|
||
|
||
These were "open questions" or "upstream changes needed" in the
|
||
2026-08-14 draft and are now resolved. The plan reflects the as-built
|
||
shape, not the original intent.
|
||
|
||
### alkcall 0.1.1 — ALPN rename done
|
||
|
||
- `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in
|
||
`alkcall/src/channels/adapter.rs`).
|
||
- Test at `channels/client.rs:767` uses `"alk/tty"` (was
|
||
`"alknet/tty"`).
|
||
- Semver showed no public API change → alkcall bumped 0.1.0 → 0.1.1.
|
||
- `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to
|
||
`0.1.1` (the scaffold still pins `0.1.0`).
|
||
|
||
### alkcall — `Identity.resources` shape
|
||
|
||
The alknet-core `Identity` had `resources: HashMap<String, String>` (a
|
||
flat resource-type → resource-id map). alkcall's `Identity` (in
|
||
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>` —
|
||
one resource type can carry multiple resource ids. The ported adapter
|
||
tests that build an `Identity` by hand need this shape; the production
|
||
code only reads `identity.scopes` (the scope-gate at negotiation), so
|
||
the change is test-side.
|
||
|
||
### alkcall — `OwnershipProvider` / `OwnershipStore` signature
|
||
|
||
- `OwnershipProvider::owns(&self, identity, resource_type,
|
||
resource_id, action: &str)` — added an `action` parameter (was 3
|
||
args, now 4). The adapter's call site
|
||
(`provider.owns(id_ref, kind, &id, "tty")`) already passes an
|
||
`"tty"` action string, which is the new arity. No change to the
|
||
production adapter; the mock and test fixtures need the 4-arg shape.
|
||
- `OwnershipStore::record(&self, identity, resource_type,
|
||
resource_id)` — **dropped** the old `action` parameter that
|
||
alknet-core's variant took. The adapter test's
|
||
`store.record(&owner, "container", "c1").await` matches the new
|
||
arity. (The scaffold's plan previously listed an `OwnershipStore`
|
||
import that no longer needs an action arg.)
|
||
|
||
### alkcall — `AuthContext::anonymous(alpn)`
|
||
|
||
alkcall added a convenience constructor `AuthContext::anonymous(alpn)`
|
||
that builds an `AuthContext` with no identity, no fingerprint, no
|
||
remote address — only the ALPN. Useful in adapter tests; not used by
|
||
the production adapter (which receives the `AuthContext` from the
|
||
dispatch path).
|
||
|
||
### alktype 0.2.0 — BAST format pivot
|
||
|
||
alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema
|
||
backend was dropped in favor of a straightforward `kind`-based BAST
|
||
(Binary Abstract Syntax Tree) format inspired by unist ASTs. A BAST
|
||
document is a plain JSON file (conforming to a JSON Schema meta-schema)
|
||
that describes binary layouts using `kind` strings (`"uint8"`,
|
||
`"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition.
|
||
|
||
`alktty` will **not** depend on alktype — the hand-rolled
|
||
`ChunkReader`/`ChunkWriter` in `wire.rs` already works and is
|
||
straightforward. The BAST format matters here only as a normative
|
||
**schema document** for the `alk/tty` wire format: a JSON file under
|
||
`docs/architecture/` that downstream consumers (and alktype, if
|
||
desired) can consume to generate validators, layout maps, or readers
|
||
in any language. The schema is a documentation artifact, not a runtime
|
||
dependency.
|
||
|
||
## Crate Structure
|
||
|
||
Single crate (`alktty`) with the local backend gated behind a `local`
|
||
feature. This is a "tty-specific monorepo" — one crate, one concern,
|
||
both halves of the original alknet split (`alknet-tty` +
|
||
`alknet-tty-local`) folded in.
|
||
|
||
```
|
||
alktty/
|
||
├── src/
|
||
│ ├── lib.rs # crate root, re-exports
|
||
│ ├── wire.rs # ChunkReader/ChunkWriter (5-byte format)
|
||
│ ├── negotiation.rs # NegotiateRequest, framing reader/writer
|
||
│ ├── control.rs # ControlMessage enum, signal_from_name
|
||
│ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError
|
||
│ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session
|
||
│ ├── session.rs # TtySession — typed consumer client
|
||
│ ├── channels.rs # OpenHandler factory, register_openable helper
|
||
│ └── local/ # (behind `local` feature — the folded alknet-tty-local)
|
||
│ ├── mod.rs # LocalTtyBackend re-export
|
||
│ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe)
|
||
│ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)])
|
||
│ └── pipe.rs # Pipe/runner mode (tokio::process::Command, cross-platform)
|
||
├── docs/
|
||
│ ├── architecture/
|
||
│ │ ├── README.md # architecture index
|
||
│ │ ├── tty-wire.md # wire format spec (5-byte chunk + 4-byte neg frame)
|
||
│ │ ├── tty-bast.md # BAST JSON document for the alk/tty wire format
|
||
│ │ ├── tty-backend.md # TtyBackend trait + LocalTtyBackend
|
||
│ │ ├── tty-adapter.md # TtyAdapter + drive_session
|
||
│ │ ├── tty-local.md # PTY + pipe modes, REQ-TTY-01/02
|
||
│ │ └── decisions/ # renumbered ADRs
|
||
│ └── plans/
|
||
│ └── project-setup.md # this document
|
||
├── Cargo.toml
|
||
├── LICENSE-APACHE
|
||
└── LICENSE-MIT
|
||
```
|
||
|
||
## Key Design Decisions
|
||
|
||
### 1. Single crate, feature-gated local backend
|
||
|
||
The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency
|
||
problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and
|
||
ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a
|
||
`local` feature — which cargo rejects. The assembly-layer workaround
|
||
(consumer depends on both crates directly) works but is awkward.
|
||
|
||
Single crate with `local` feature solves this cleanly. The
|
||
`portable-pty` and `tokio-util` deps are optional. A docker-only
|
||
deployment doesn't pull in PTY code.
|
||
|
||
**Folded-in scope.** The single crate carries both halves of the
|
||
original alknet split: the wire/adapter/backend-trait half (from
|
||
`alknet-tty`) and the local backend (from `alknet-tty-local`). The
|
||
local backend is small (~1.4k lines), tightly coupled to the trait,
|
||
and the cyclic-dep workaround it required in the alknet mono-repo
|
||
doesn't exist in a single crate. A docker or SSH backend would still
|
||
be a separate crate — those have real external deps (`bollard`,
|
||
`russh`) and their own resource models.
|
||
|
||
**WASM target.** The default crate (no `local` feature) MUST compile
|
||
to `wasm32-unknown-unknown`. alkcall and alktype both target wasm;
|
||
keeping alktty wasm-clean is what makes the downstream TS/Python
|
||
adapter story work — a wasm-compiled alktty is the protocol layer for
|
||
a sandboxed adapter (browser terminal over WebTransport, a Python
|
||
wheel that shells out to a wasm module, etc.). The `local` feature is
|
||
inherently non-wasm (`portable-pty` needs a real OS, `tokio::process`
|
||
needs `spawn`), so enabling `local` on wasm is a build error by
|
||
design — the local-process backend runs on a real OS (Linux, macOS,
|
||
Windows), never in a sandbox.
|
||
|
||
Concretely, `Cargo.toml` uses `tokio = { default-features = false,
|
||
features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean
|
||
subset alkcall uses) and `local` adds `tokio/process` +
|
||
`tokio/rt-multi-thread`. Do NOT use `features = ["full"]` — it pulls
|
||
in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The
|
||
adapter's `tokio::spawn` for per-session pumps is fine on wasm (the
|
||
wasm tokio runtime supports `spawn`); the `local` module's std
|
||
threads and `tokio::process::Command` are the non-wasm parts, and
|
||
they're feature-gated. `libc` stays under `cfg(unix)` (it's already
|
||
there for `signal_from_name` and the pipe-mode `kill` path — neither
|
||
runs on wasm because the only callers are in `local`).
|
||
|
||
### 2. Dependency: `alknet-core` → `alkcall::core`
|
||
|
||
All types previously from `alknet-core` now come from `alkcall::core`:
|
||
- `ProtocolHandler`, `Connection`, `BiStream`, `BidiStreamSource`
|
||
- `AuthContext`, `Identity`, `IdentityProvider`
|
||
- `AccessControl`, `OwnershipProvider`, `OwnershipStore`,
|
||
`InMemoryOwnershipStore`
|
||
- `HandlerError`, `StreamError`
|
||
|
||
The `TtyAdapter`'s `impl ProtocolHandler` changes which crate the trait
|
||
comes from. The trait shape is identical. The `Identity` struct's
|
||
`resources` field is `HashMap<String, Vec<String>>` in alkcall (was
|
||
`HashMap<String, String>` in alknet-core); the production adapter only
|
||
reads `identity.scopes`, so the change is test-side.
|
||
|
||
`Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is
|
||
stale — 0.1.1 is the ALPN-rename release; same public API per semver).
|
||
|
||
### 3. Channels integration (the new part)
|
||
|
||
This is where alktty becomes a proper producer/consumer on alkcall.
|
||
|
||
**ALPN strings (settled).** `alk/tty` (direct) and `alk/channels`
|
||
(multiplexed). The `alknet/<name>` → `alk/<name>` rename landed in
|
||
alkcall 0.1.1 — `CHANNELS_ALPN` is `b"alk/channels"`, the channels
|
||
client test uses `"alk/tty"`. No upstream work remains; we just use
|
||
the new strings.
|
||
|
||
**Producer half** — two paths to expose TTY:
|
||
|
||
- **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`.
|
||
Existing code, just import migration. Used for direct-connect scenarios
|
||
(browser terminal over WebTransport, standalone TTY endpoint).
|
||
|
||
- **Through channels** (`alk/channels`): Register an `OpenHandler` via
|
||
`ChannelCore::register_openable()`. The handler receives a `Connection`
|
||
(the channel's data plane with ALPN `alk/tty`), calls `accept_bi()`,
|
||
and runs the same `drive_session` on the resulting `BiStream`.
|
||
|
||
Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format.
|
||
The channels layer strips its 8-byte header and hands TTY the payload
|
||
transparently. The same `wire.rs` code runs in both modes — only the
|
||
`BiStream` source differs.
|
||
|
||
The channels integration code is small: a `register_openable` helper
|
||
function that takes a `ChannelCore`, `OperationRegistry`, and backend
|
||
map, and registers the per-ALPN open op (`channels/tty/sub` —
|
||
consumer-opens). This lives in `src/channels.rs`. The op spec carries
|
||
the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the
|
||
`AccessControl` for the scope-gate + ownership check; the channels
|
||
wrapper does `check_open` → `open_channel` → spawn the `OpenHandler`
|
||
→ respond with `{ channel_id }`.
|
||
|
||
**Consumer half** — `TtySession`:
|
||
|
||
A typed client that wraps the wire protocol. Two constructors:
|
||
- `TtySession::connect_direct(connection)` — for direct `alk/tty`
|
||
- `TtySession::open_via_channels(client, params)` — opens a channel via
|
||
`ChannelClient`, negotiates, returns a session handle
|
||
|
||
The session handle exposes:
|
||
- `send_stdin(bytes)` / `close_stdin()`
|
||
- `recv_stdout()` / `recv_stderr()` — streams
|
||
- `resize(cols, rows)`, `signal(name)`
|
||
- `wait()` — awaits exit code
|
||
|
||
### 4. Negotiation framing — keep self-contained
|
||
|
||
ADR-057 says the negotiation framing is self-contained (~30 lines, format
|
||
coincides with alkcall's by convention). Even though alkcall is now a
|
||
dependency, we keep the self-contained framing because:
|
||
|
||
- The payload is `NegotiateRequest`, not `EventEnvelope` — alkcall's
|
||
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`
|
||
- The framing is trivial (4-byte BE length prefix + body)
|
||
- Avoids coupling to alkcall's internal wire types for a TTY-specific payload
|
||
|
||
### 5. Producer/consumer framing
|
||
|
||
The architecture README in alkcall describes protocol crates as having two
|
||
halves:
|
||
|
||
1. **Producer half** — `register_*()` functions that take an
|
||
`&mut OperationRegistry` and register ops with handlers. For channels,
|
||
an `OpenHandler` factory.
|
||
|
||
2. **Consumer half** — a typed client wrapper around `CallConnection` or
|
||
`ChannelClient` that exposes the crate's ops as async methods.
|
||
|
||
We make this explicit in the module structure:
|
||
- `src/adapter.rs` + `src/channels.rs` = producer half
|
||
- `src/session.rs` = consumer half
|
||
|
||
### 6. Access control and auth
|
||
|
||
The old code had minimal access control (a scope-gate at negotiation).
|
||
With alkcall, we get:
|
||
|
||
- **Operation-level ACL**: `AccessControl` on `OperationSpec` — scopes,
|
||
resource ownership checks (`required_scopes`, `required_scopes_any`,
|
||
`resource_type` + `resource_id_path`, `resource_action`)
|
||
- **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity
|
||
channel caps, open/close hooks (consulted by the `ChannelCore`
|
||
wrapper, not by alktty directly)
|
||
- **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking
|
||
(terminal sessions are resources per ADR-050). alkcall's
|
||
`OwnershipProvider::owns` takes an `action: &str` arg (the adapter
|
||
passes `"tty"`); `OwnershipStore::record` takes no action arg.
|
||
|
||
### 7. BAST schema as a documentation artifact
|
||
|
||
The `alk/tty` wire format gets a BAST (Binary Abstract Syntax Tree)
|
||
document under `docs/architecture/tty-bast.md`. BAST is alktype's
|
||
JSON-based binary-layout vocabulary (alktype 0.2.0 dropped the old
|
||
`AlkType:*` custom-keyword backend in favor of this `kind`-based
|
||
format). The document is a normative spec: downstream consumers (and
|
||
alktype, if desired) can consume it to generate validators, layout
|
||
maps, or readers in any language with a JSON parser.
|
||
|
||
**alktty does not depend on alktype.** The hand-rolled
|
||
`ChunkReader`/`ChunkWriter` in `wire.rs` is the runtime codec; the BAST
|
||
document is the human-readable contract that describes what those
|
||
types round-trip. Keeping them as separate artifacts avoids pulling in
|
||
alktype as a runtime dep for a codec that's already ~200 lines and
|
||
works. If we later want runtime validation against the BAST (e.g., to
|
||
reject malformed chunks at the framing boundary with a generated
|
||
validator), alktype becomes an optional dep — the BAST document is
|
||
already there to feed it.
|
||
|
||
The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope
|
||
check (`has_scope(identity, TTY_OPEN_SCOPE)`) for the scope gate and
|
||
optionally consults `OwnershipProvider` for the resource-ownership
|
||
check. 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.
|
||
|
||
## Migration Strategy
|
||
|
||
### Phase 0: Scaffold hygiene (small fixes before porting)
|
||
|
||
1. Bump `alkcall` in `Cargo.toml` from `"0.1.0"` → `"0.1.1"` (ALPN
|
||
rename release). *(Done in this revision.)*
|
||
2. Add `futures = "0.3"` to `[dependencies]` (the `BoxFuture` alias
|
||
uses `futures::future::BoxFuture`; alkcall pulls it transitively
|
||
but alktty should declare it directly to not rely on a transitive
|
||
dep for a public type alias). *(Done.)*
|
||
3. **Split tokio features for the WASM target.** *(Done.* `Cargo.toml`
|
||
*now uses `tokio = { default-features = false, features = ["rt",
|
||
"sync", "io-util", "macros"] }` for the default wasm-clean build,
|
||
and `local` adds `tokio/process` + `tokio/rt-multi-thread`.)* The
|
||
scaffold's `features = ["full"]` pulled in `signal`/`fs`/`net`
|
||
which break `wasm32-unknown-unknown`. The dev-deps that need
|
||
`tokio/process` (the pipe/pty integration tests) get it via the
|
||
`local` feature on the crate itself, not by re-declaring `full`.
|
||
4. Confirm the `local` feature already wires `portable-pty` +
|
||
`tokio-util` as optional deps (it does in the scaffold). The
|
||
`local` module's `pty.rs` additionally needs `libc` under
|
||
`cfg(unix)` — already in `[target.'cfg(unix)'.dependencies]`.
|
||
|
||
### Phase 1: Port core types from `alknet-tty` (no new logic)
|
||
|
||
1. Port `wire.rs` — no `alknet-core` imports; just `std::io`, `tokio::io`,
|
||
`bytes`, `thiserror`. Rename crate references in doc comments
|
||
(`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`).
|
||
2. Port `control.rs` — no `alknet-core` imports; `serde`, `bytes`,
|
||
`libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in
|
||
doc comments.
|
||
3. Port `negotiation.rs` — no `alknet-core` imports; `std::io`,
|
||
`bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc
|
||
references.
|
||
4. Port `backend.rs` — no `alknet-core` imports; `std`, `async_trait`,
|
||
`bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the
|
||
`BoxFuture` type alias from `Pin<Box<dyn Future + Send>>` to
|
||
`futures::future::BoxFuture<'static, T>` (functionally identical;
|
||
the alias matches the alkcall convention and lets us drop the
|
||
`Pin`/`Future` imports).
|
||
5. Port `adapter.rs` — change imports:
|
||
- `alknet_core::auth::{AuthContext, Identity}` →
|
||
`alkcall::core::{AuthContext, Identity}`
|
||
- `alknet_core::ownership::OwnershipProvider` →
|
||
`alkcall::core::OwnershipProvider`
|
||
- `alknet_core::types::{Connection, HandlerError, ProtocolHandler,
|
||
StreamError}` → `alkcall::core::{Connection, HandlerError,
|
||
ProtocolHandler, StreamError}`
|
||
- Change `b"alknet/tty"` → `b"alk/tty"` in `TtyAdapter::alpn()`.
|
||
- The `handle()` method signature is identical (alkcall's
|
||
`ProtocolHandler` shape matches alknet-core's).
|
||
6. Port the unit tests inside each module alongside the production
|
||
code. The adapter tests need:
|
||
- `Identity.resources: HashMap<String, Vec<String>>` (was
|
||
`HashMap<String, String>` in alknet-core).
|
||
- `OwnershipStore::record(&self, identity, resource_type,
|
||
resource_id)` — 3 args, no `action` (alkcall dropped it).
|
||
- `OwnershipProvider::owns(identity, rt, rid, action)` — 4 args
|
||
(alkcall added `action`).
|
||
|
||
### Phase 2: Channels integration (new code)
|
||
|
||
1. Create `src/channels.rs`:
|
||
- `register_openable` helper — takes `ChannelCore`, backend map,
|
||
`OperationRegistry`, `AuthContext`; builds the `OperationSpec`
|
||
for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`,
|
||
`AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership
|
||
if a backend declares a `resource_id`; calls
|
||
`ChannelCore::register_openable(spec, open_handler, registry, auth)`.
|
||
- `TtyOpenHandler` — the `OpenHandler` (an
|
||
`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
|
||
that receives a channel `Connection`, calls `accept_bi()`, runs
|
||
`drive_session` on the resulting `BiStream`.
|
||
2. Create `src/session.rs`:
|
||
- `TtySession` struct — typed consumer client
|
||
- `TtySession::connect_direct(connection)` — direct `alk/tty`
|
||
- `TtySession::open_via_channels(client, params)` — opens a channel
|
||
via `ChannelClient::open_channel("channels/tty/sub", params,
|
||
"alk/tty")`, negotiates, returns a session handle
|
||
- Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`,
|
||
`signal`, `wait`
|
||
|
||
### Phase 3: Local backend (behind `local` feature) — folded `alknet-tty-local`
|
||
|
||
1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`),
|
||
`src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`.
|
||
2. Port from `alknet-tty-local/src/`:
|
||
- `backend.rs` — change `alknet_tty::backend::{...}` →
|
||
`crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches
|
||
on `params.terminal`: `Some` → `pty::allocate_pty`, `None` →
|
||
`pipe::allocate_pipe`.
|
||
- `pty.rs` — `portable_pty` + 3 std threads (reader/writer/waiter)
|
||
feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses
|
||
`libc::kill(-pgid, sig)` for process-group signal forwarding.
|
||
Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` →
|
||
`crate::backend::{...}` and `alknet_tty::control::signal_from_name`
|
||
→ `crate::control::signal_from_name`.
|
||
- `pipe.rs` — `tokio::process::Command` + `tokio_util::io::ReaderStream`
|
||
for stdout/stderr. Cross-platform: signal forwarding uses
|
||
`libc::kill(pid, sig)` under `cfg(unix)`, falls back to
|
||
`Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`.
|
||
3. `LocalTtyBackend` implements `crate::backend::TtyBackend`. Wire the
|
||
`local` feature in `src/lib.rs`:
|
||
`#[cfg(feature = "local")] pub mod local;`
|
||
|
||
### Phase 4: Architecture docs + BAST schema — landed 2026-08-17
|
||
|
||
1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/`
|
||
into `docs/architecture/` (flat layout, not the `crates/tty/`
|
||
subpath — this is a single-crate repo now):
|
||
`overview.md` (the crate overview; the alknet `README.md` index is
|
||
ported as `docs/architecture/README.md`), `tty-wire.md`,
|
||
`tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed
|
||
`alknet-tty` → `alktty`, `alknet/tty` → `alk/tty`, `alknet-core` →
|
||
`alkcall::core`, `alknet-call` → `alkcall`, `alknet-tty-local` →
|
||
alktty's `local` feature module, and cross-references to alknet
|
||
ADRs are renumbered to alktty's ADR range (001..008).
|
||
2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093)
|
||
into `docs/architecture/decisions/` renumbered 001..008 in order:
|
||
- [001](../architecture/decisions/001-wire-format-and-two-carriage.md)
|
||
← alknet ADR-052 — wire format + two-carriage model (incl. the
|
||
Phase 7 control-channel split amendment)
|
||
- [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md)
|
||
← alknet ADR-053 — `TtyBackend` trait + `TtyHandle`
|
||
- [003](../architecture/decisions/003-local-backend-placement.md)
|
||
← alknet ADR-054 — local backend placement (records both the
|
||
alknet sibling-crate decision and the alktty single-crate
|
||
consolidation behind a `local` feature)
|
||
- [004](../architecture/decisions/004-exit-code-on-control-chunk.md)
|
||
← alknet ADR-055 — exit code on a control chunk
|
||
- [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md)
|
||
← alknet ADR-056 — backend cleanup on session cancel
|
||
- [006](../architecture/decisions/006-negotiation-framing-self-contained.md)
|
||
← alknet ADR-057 — self-contained negotiation framing
|
||
- [007](../architecture/decisions/007-tty-inside-channels.md)
|
||
← alknet ADR-077 — TTY inside channels (reversed by 008; kept for
|
||
historical context with its reversal notice pointing to 008)
|
||
- [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md)
|
||
← alknet ADR-093 — channels pure channel multiplexing (reverses
|
||
007; TTY always uses its 5-byte format)
|
||
3. Wrote
|
||
[`docs/architecture/tty-bast.md`](../architecture/tty-bast.md) —
|
||
the BAST (Binary Abstract Syntax Tree) document for the `alk/tty`
|
||
wire format. Conforms to the BAST meta-schema at
|
||
`https://alk.dev/bast/v1/schema`; validatable by any JSON Schema
|
||
Draft 2020-12 validator. Covers:
|
||
- The 5-byte chunk header (`struct` with `endian: "big"`:
|
||
`stream_type: uint8`, `length: uint32`).
|
||
- The five stream-type channels as an `enum` (`Stdin=0`,
|
||
`Stdout=1`, `Stderr=2`, `CtrlIn=3`, `CtrlOut=4`).
|
||
- The control-message `union` (field-name discriminator on `type`:
|
||
`resize`, `signal`, `eof`, `exit`) with the documented deviation
|
||
that on-wire control payloads are UTF-8 JSON, not BAST's binary
|
||
union encoding.
|
||
- The negotiation frame as a separate `struct` (4-byte BE length
|
||
prefix + UTF-8 JSON `NegotiateRequest` body) — annotated as
|
||
out-of-band for the chunk codec but documented for completeness.
|
||
- The `StreamType` enum's documented deviation: on-wire encoding is
|
||
`uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes)
|
||
— the chunk header is 5 bytes, not 8.
|
||
4. Wrote [`docs/architecture/README.md`](../architecture/README.md) —
|
||
the architecture index: documents table, ADR table (with alknet
|
||
origin numbers and port status), key design principles, open
|
||
questions, and references.
|
||
|
||
The ADR mapping follows the plan's 8-to-8 list (ADR-052→001, 053→002,
|
||
054→003, 055→004, 056→005, 057→006, 077→007, 093→008). ADR-050
|
||
(dynamic resource ownership) is an alkcall/alknet-core ADR, not
|
||
tty-specific, and is not ported into alktty's ADR range; the
|
||
access-control work that declares against the ADR-050 model is
|
||
described in `tty-adapter.md` and the ADR-001/002 ported docs, which
|
||
reference ADR-050 by its alknet number. The Phase 7 control-channel
|
||
split (`STREAM_CTRL_IN` = 3, `STREAM_CTRL_OUT` = 4) is an amendment
|
||
inside ADR-001, mirroring alknet (it was not a standalone ADR there
|
||
either). `AGENTS.md` was updated to match this mapping.
|
||
|
||
### Phase 5: Tests — landed 2026-08-17
|
||
|
||
1. Ported existing unit tests from `alknet-tty` (wire, negotiation,
|
||
control, adapter) inline in each `src/*.rs` module's
|
||
`#[cfg(test)] mod tests` — done in Phases 1–2 alongside the
|
||
production code (80 lib tests, all passing).
|
||
2. Ported integration tests from `alknet-tty-local/tests/` into
|
||
`tests/` at the crate root:
|
||
- [`tests/common/mod.rs`](../tests/common/mod.rs) — the
|
||
`ClientSide` wire-protocol harness + `spawn_session` helper +
|
||
`negotiate_pty_json` / `negotiate_pipe_json` builders. Imports
|
||
renamed `alknet_core::auth::Identity` →
|
||
`alkcall::core::auth::Identity`, `alknet_tty::...` →
|
||
`alktty::...`.
|
||
- [`tests/negotiation.rs`](../tests/negotiation.rs) — 4
|
||
negotiation-error scenarios (unknown_backend,
|
||
malformed_negotiation ×3, allocate_failed).
|
||
- [`tests/pipe.rs`](../tests/pipe.rs) — 6 pipe-mode scenarios
|
||
(echo happy path, separate stderr, SIGTERM, cancel cleanup,
|
||
resize no-op, stdout sentinel). The 2 cancel-cleanup /
|
||
SIGTERM tests are `#[cfg(unix)]`.
|
||
- [`tests/pty.rs`](../tests/pty.rs) — 8 PTY-mode scenarios
|
||
(echo, interactive cat, resize, SIGINT, process-group
|
||
signal, stdin-EOF sentinel, cancel cleanup,
|
||
exit-chunk-is-last). The 4 signal / cancel-cleanup tests
|
||
are `#[cfg(unix)]`.
|
||
Each test file carries `#![cfg(feature = "local")]` so the
|
||
default crate (no features) skips the integration binaries
|
||
and stays wasm-buildable; `cargo test --all-features` runs
|
||
all 19 integration tests (5 + 6 + 8).
|
||
3. Added channels integration tests inline in
|
||
[`src/channels.rs`](../src/channels.rs) `mod tests` (done in
|
||
Phase 2 alongside the producer code): `register_openable`
|
||
registration, end-to-end `ChannelClient::call_open_op` returns
|
||
`channel_id`, scope-gate denies without `tty:open`, and the
|
||
`MockBackend` exit-code sanity check. Uses the in-memory
|
||
`MockBackend` from `backend.rs` for the producer side so no
|
||
real PTY is needed.
|
||
|
||
Total: 119 lib tests + 19 integration tests = 138 passing under
|
||
`--all-features`; 104 lib tests under default (wasm-clean) build
|
||
(counts as of the review #003 remediation; the suites have grown since
|
||
the 80/19 the plan originally recorded).
|
||
|
||
## Open Questions
|
||
|
||
### OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1)
|
||
|
||
**Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The
|
||
`alknet/<name>` → `alk/<name>` rename landed in alkcall 0.1.1:
|
||
`CHANNELS_ALPN` is `b"alk/channels"`, the channels client test uses
|
||
`"alk/tty"`. Semver showed no public API change → 0.1.0 → 0.1.1.
|
||
|
||
### OQ-2: Channel open operation name (RESOLVED)
|
||
|
||
The alkcall test uses `channels/tty/sub` as the operation name. The
|
||
convention from ADR-047 is `channels/<alpn>/sub` for subscribe (consumer
|
||
opens) and `channels/<alpn>/pub` for publish (producer opens). Since TTY
|
||
is consumer-opens (the client requests a shell), `channels/tty/sub` is
|
||
correct.
|
||
|
||
**Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub`
|
||
reserved for future producer-opens use case.
|
||
|
||
### OQ-3: `TtySession` API — stream vs callback (RESOLVED)
|
||
|
||
**Decision**: Stream-based (`Stream<Item = Bytes>`). Consistent with the
|
||
backend trait's `TtyHandle` shape.
|
||
|
||
### OQ-4: `drive_session` — keep the three-pump pattern? (RESOLVED)
|
||
|
||
**Decision**: Keep it. It works, it's tested, and ADR-093 means the same
|
||
code runs in both direct and channels modes.
|
||
|
||
### OQ-5: `BoxFuture` type alias (RESOLVED)
|
||
|
||
**Decision**: Use `futures::future::BoxFuture<'static, T>`. alkcall
|
||
already pulls in `futures`, so no new transitive dep; alktty declares
|
||
`futures = "0.3"` directly so the public alias doesn't ride on a
|
||
transitive dep.
|
||
|
||
### OQ-6: BAST schema scope (RESOLVED)
|
||
|
||
**Decision**: Documentation artifact only — `docs/architecture/tty-bast.md`.
|
||
alktty does not depend on alktype. The hand-rolled `wire.rs` codec is
|
||
the runtime; the BAST document is the normative contract for downstream
|
||
consumers. If runtime validation against the BAST becomes desirable
|
||
later, alktype becomes an optional dep and the BAST document is already
|
||
there to feed it.
|
||
|
||
## Upstream Changes Needed in alkcall
|
||
|
||
All four landed in alkcall 0.1.1 (no public API change per semver).
|
||
Nothing is blocking alktty; this section is kept as a historical record
|
||
of what was pushed.
|
||
|
||
1. **ALPN convention**: `alknet/<name>` → `alk/<name>` across docs, ADRs,
|
||
and test code. The test at `channels/client.rs:767` now uses
|
||
`ChannelOpenSpec::new("alk/tty")` (was `"alknet/tty"`).
|
||
|
||
2. **`CHANNELS_ALPN` constant**: `b"alknet/channels"` → `b"alk/channels"`
|
||
in `alkcall/src/channels/adapter.rs`.
|
||
|
||
3. **ADR-004** (ALPN convention): Updated from `alknet/` prefix to `alk/`.
|
||
|
||
4. **Architecture README**: The dependency layering diagram and pattern
|
||
section reference `alk/tty` and `alk/channels`.
|
||
|
||
## Risks and Concerns
|
||
|
||
### Risk: alkcall is v0.1.x — API stability
|
||
|
||
alkcall is published at 0.1.1. Breaking changes are expected at this
|
||
major-zero stage. The integration surface we use
|
||
(`ChannelCore::register_openable`, `ChannelClient`, `ProtocolHandler`,
|
||
`Connection`, `Identity`, `OwnershipProvider`) is the core API and
|
||
likely stable, but the channels module in particular may evolve.
|
||
|
||
**Mitigation**: We own alkcall and can push changes as needed. This is
|
||
the first real consumer, so we'll find and fix issues upstream rather
|
||
than working around them. Pin `alkcall = "0.1.1"` and bump deliberately.
|
||
|
||
### Risk: `drive_session` complexity
|
||
|
||
The three-pump driver is ~1500 lines with edge cases around exit ordering,
|
||
cancel cleanup, and bidirectional control. Porting it is mechanical but
|
||
needs careful review.
|
||
|
||
**Mitigation**: The existing code has extensive tests. Port tests first
|
||
or alongside.
|
||
|
||
### Risk: Local backend PTY bridge
|
||
|
||
The PTY mode uses 3 std threads feeding tokio channels. This is a
|
||
well-understood pattern (wezterm uses it) but is inherently platform-
|
||
specific (Unix only for PTY, pipe mode works cross-platform).
|
||
|
||
**Mitigation**: Gate PTY behind `#[cfg(unix)]` as the existing code does.
|
||
Pipe mode works on all platforms. The whole `local` module is behind
|
||
the `local` feature, which is non-wasm by design.
|
||
|
||
### Risk: WASM target regression
|
||
|
||
The default crate MUST stay `wasm32-unknown-unknown`-clean. A future
|
||
change that pulls in `tokio::process`, `std::thread`, `libc`, or any
|
||
OS-specific dep into a non-`local` module silently breaks the
|
||
downstream TS/Python adapter story (the wasm-compiled protocol layer
|
||
is what makes those adapters cheap to build).
|
||
|
||
**Mitigation**: Add a CI job that runs `cargo check --target
|
||
wasm32-unknown-unknown` (no features) on every PR. Cheap, catches the
|
||
regression at the boundary. The tokio feature split in `Cargo.toml`
|
||
(the wasm-clean `["rt", "sync", "io-util", "macros"]` subset, with
|
||
`local` adding `process`/`rt-multi-thread`) is the structural guard;
|
||
the CI job is the enforcement.
|
||
|
||
### Risk: BAST schema drift
|
||
|
||
The BAST document in `docs/architecture/tty-bast.md` is a documentation
|
||
artifact; the runtime codec is the hand-rolled `wire.rs`. If the wire
|
||
format evolves (new stream_type, control message variant, negotiation
|
||
field) and the BAST doc isn't updated, downstream consumers generated
|
||
from the BAST will be wrong.
|
||
|
||
**Mitigation**: Add a test that parses the BAST document with
|
||
`serde_json` and asserts the stream-type enum values match
|
||
`wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
|
||
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the
|
||
common drift case. (Doesn't require alktype as a dep — just
|
||
`serde_json`, which we already have.)
|
||
|
||
## References
|
||
|
||
- [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md)
|
||
- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs) — `ChannelCore::register_openable`, `OpenHandler`
|
||
- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs) — `ChannelClient::open_channel`, reference integration pattern
|
||
- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md) — normative format spec for the `tty-bast.md` document
|
||
- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/) — port origin (wire, control, negotiation, backend, adapter)
|
||
- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/) — port origin (local backend, pty, pipe)
|
||
- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) — port origin for the spec docs
|
||
- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md) — TTY always uses 5-byte format
|
||
- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — reversed, historical context
|