diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0d13201 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "alktty" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +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" +keywords = ["tty", "terminal", "pty", "channels", "alkcall"] +categories = ["network-programming", "asynchronous"] +exclude = [".opencode/", "docs/reviews/", "docs/research/", "docs/sdd_process.md", "Cargo.lock"] + +[lib] +name = "alktty" + +[features] +default = [] +local = ["dep:portable-pty", "dep:tokio-util"] + +[dependencies] +alkcall = "0.1.0" +tokio = { version = "1", features = ["full"] } +bytes = "1" +futures-core = "0.3" +tokio-stream = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +tracing = "0.1" +thiserror = "2" + +portable-pty = { version = "0.9", optional = true } +tokio-util = { version = "0.7", features = ["io"], optional = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util", "macros"] } +tokio-stream = "0.1" +serde_json = "1" +tempfile = "3" +bytes = "1" diff --git a/docs/plans/project-setup.md b/docs/plans/project-setup.md new file mode 100644 index 0000000..bc83f0e --- /dev/null +++ b/docs/plans/project-setup.md @@ -0,0 +1,299 @@ +# Project Setup Plan + +Status: draft +Last updated: 2026-08-14 + +## 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. + +## Crate Structure + +Single crate (`alktty`) with the local backend gated behind a `local` +feature. This is a "tty-specific monorepo" — one crate, one concern. + +``` +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) +│ ├── mod.rs +│ ├── backend.rs # LocalTtyBackend +│ ├── pty.rs # PTY mode (portable_pty) +│ └── pipe.rs # Pipe/runner mode (tokio::process::Command) +├── docs/ +│ ├── architecture/ +│ │ ├── README.md # architecture index +│ │ ├── crates/ +│ │ │ ├── overview.md +│ │ │ ├── tty-wire.md +│ │ │ ├── tty-backend.md +│ │ │ ├── tty-adapter.md +│ │ │ └── tty-local.md +│ │ └── 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` +dep is optional. A docker-only deployment doesn't pull in PTY code. + +### 2. Dependency: `alknet-core` → `alkcall` + +All types previously from `alknet-core` now come from `alkcall::core`: +- `ProtocolHandler`, `Connection`, `BiStream`, `BidiStreamSource` +- `AuthContext`, `Identity`, `IdentityProvider` +- `AccessControl`, `OwnershipProvider` +- `HandlerError`, `StreamError` + +The `TtyAdapter`'s `impl ProtocolHandler` changes which crate the trait +comes from. The trait shape is identical. + +### 3. Channels integration (the new part) + +This is where alktty becomes a proper producer/consumer on alkcall. + +**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. This lives in `src/channels.rs`. + +**Consumer half** — `TtySession`: + +A typed client that wraps the wire protocol. Two constructors: +- `TtySession::connect_direct(connection)` — for direct `alknet/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 +- **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity + channel caps, open/close hooks +- **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking + (terminal sessions are resources per ADR-050) + +The `TtyAdapter`'s access control is reworked to use alkcall's primitives +rather than the ad-hoc scope check. The channels path gets this for free +via `ChannelCore::register_openable` which wires `AccessControl` into the +operation spec. + +## Migration Strategy + +### Phase 1: Scaffold and core types (no new logic) + +1. Create `Cargo.toml` with alkcall dependency +2. Port `wire.rs` — change `alknet-core` imports to `alkcall::core`, rename + crate references in docs +3. Port `control.rs` — same +4. Port `negotiation.rs` — same +5. Port `backend.rs` — same; `BoxFuture` type alias can use + `futures::future::BoxFuture` since alkcall pulls in `futures` +6. Port `adapter.rs` — change `ProtocolHandler` import, `Connection` import, + `AuthContext` import, `HandlerError` import. The `handle()` method + signature is identical. + +### Phase 2: Channels integration (new code) + +1. Create `src/channels.rs`: + - `register_openable` helper — takes `ChannelCore`, backend map, + `OperationRegistry`, registers the `channels/tty/sub` op + - `TtyOpenHandler` — the `OpenHandler` that receives a channel + `Connection`, calls `accept_bi()`, runs `drive_session` +2. Create `src/session.rs`: + - `TtySession` struct — typed consumer client + - `TtySession::open_via_channels(client, params)` constructor + - Methods: `send_stdin`, `recv_stdout`, `resize`, `signal`, `wait` + +### Phase 3: Local backend (behind `local` feature) + +1. Create `src/local/mod.rs`, `src/local/backend.rs`, `src/local/pty.rs`, + `src/local/pipe.rs` +2. Port from `alknet-tty-local` — change `alknet-tty` imports to `crate` +3. `LocalTtyBackend` implements `crate::backend::TtyBackend` + +### Phase 4: Architecture docs + +1. Port the 5 spec docs from `alknet/docs/architecture/crates/tty/` +2. Port relevant ADRs (052, 053, 054, 055, 056, 057, 077, 093) — + renumber into alktty's ADR range, update cross-references +3. Write `docs/architecture/README.md` index + +### Phase 5: Tests + +1. Port existing unit tests from `alknet-tty` (wire, negotiation, control, + adapter) +2. Port integration tests from `alknet-tty-local` (negotiation, pipe, pty) +3. Add channels integration tests — end-to-end `register_openable` + + `ChannelClient::open_channel` + `drive_session` round-trip + +## Open Questions + +### OQ-1: ALPN strings (RESOLVED) + +**Decision**: Use `alk/tty`. The `alknet/` convention is being +shortened to `alk/` across all crates. This is a wire-format change +but alkcall is v0.1.0 and this is the first protocol crate built on it — +the right time to make the change. + +**Upstream change needed**: alkcall's test at `channels/client.rs:767` uses +`ChannelOpenSpec::new("alknet/tty")` — needs updating to `"alk/tty"`. +Also any docs/ADRs in alkcall that reference `alknet/` as the ALPN +convention. + +### 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//sub` for subscribe (consumer +opens) and `channels//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`). 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`. alkcall already pulls in +`futures`, so no new dependency. + +## Upstream Changes Needed in alkcall + +These are changes we need to push to alkcall before or alongside this work: + +1. **ALPN convention**: `alknet/` → `alk/` across docs, ADRs, + and test code. The test at `channels/client.rs:767` uses + `ChannelOpenSpec::new("alknet/tty")` — needs `"alk/tty"`. + +2. **`CHANNELS_ALPN` constant**: `b"alknet/channels"` → `b"alk/channels"`. + This is in `alkcall/src/channels/adapter.rs`. + +3. **ADR-004** (ALPN convention): Update from `alknet/` prefix to `alk/`. + +4. **Architecture README**: The dependency layering diagram and pattern + section reference `alknet/tty` and `alknet/channels` — update to + `alk/tty` and `alk/channels`. + +## Risks and Concerns + +### Risk: alkcall is v0.1.0 — API stability + +alkcall is published at 0.1.0. Breaking changes are expected. The +integration surface we use (`ChannelCore::register_openable`, +`ChannelClient`, `ProtocolHandler`, `Connection`) 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. + +### 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. + +## References + +- [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md) +- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) +- [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 +- [alkcall channels client test](/workspace/@alkdev/alkcall/src/channels/client.rs) — reference integration pattern