--- id: w3-tty-session name: "Wave 3: session → alktty (translation bridge + exit derivation)" status: completed depends_on: [w3-git-mapping] scope: moderate risk: medium impact: phase level: implementation --- ## Description Land the session branch, the biggest single-row surface of the spine (mapping.md's `session` rows): pty/shell/pipe-exec become a real serving path through the published alktty seam, as a **translation bridge, not a byte pump** (POC-3 §D.1). Everything POC-3 stage D proved, re-landed as contract: - **The per-channel state + the store-and-take reuse**: the channel store `w3-git-mapping` lands (`channels.remove(&id)` → `into_stream()` in the spawned serving task) gains the session rows' per-channel state, the PTY params a `pty_request` delivered (consumed by the later `shell_request`/`exec_request` on the same channel, since `ssh -t host "cmd"` sends pty-then-exec and must serve in PTY-mode; a `shell_request` without a preceding pty serves **pipe-mode** `tty = None`, and whether such a shell serves or declines is for the task to decide and record). - **Negotiation, built locally, never wire-derived** (OQ-08: the serve-config is the whole truth): `pty_request` args → `TerminalParamsWire{term, cols, rows, pixel_w/h, modes}` (SSH ttymodes → the reserved modes JSON, which is alktty OQ-44's reservation, deferred there); `shell_request` → `NegotiateRequest{carriage: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: session_cwd, env: session_env, backend: the seam selector}` → **`drive_session_pre_negotiated(writer, reader, req, backends, ownership, identity)`** spawned; `exec_request` (pipe row) → `cmd = wrapper ++ [cmd]` (the OQ-08 argv append with vanilla quoting semantics; **never naively split**), `tty = None` pipe mode. **Unset-declines** (OQ-08): `shell_argv` unset → `shell_request` declines; `exec_wrapper_argv` unset → the pipe row declines (never a fallback). Decide the wire shape per row (capability decline, channel stays usable, vs the serving-path failure+close shape) and record. - **The translation bridge**, two small adapters around a duplex the serving task drives (the POC-3 §D.1 shape): SSH `data`/EOF → `Chunk::stdin` (decide the EOF translation: the zero-length stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN; verify what alktty's ChunkReader/backend actually consumes and record); stdout chunks → SSH `data`; stderr chunks → `extended_data(1)` (PTY mode merges upstream, where the backend's stderr stream is `None`, so the translation is universal with no mode-switch); the exit chunk (`STREAM_CTRL_OUT`) → the derivation contract. No unbounded buffering in either adapter (convention 9). - **Control rows**: `window_change_request` → `ControlMessage:: Resize` CTRL_IN; `signal` → `ControlMessage::Signal` (alktty's uppercase-name vocabulary, with the name accepted verbatim; decide the decline shape for names alktty's backend semantics reject and record). Mechanics: the requests arrive on the packet loop after the serving task owns the wire, so reach them into the spawned bridge through a per-channel control surface (a bounded mpsc; decide plus record; a silent unbounded proxy is a bounded-resources bug). - **The exit derivation contract** (mapping.md rules 1–6, normative): the alktty exit chunk (`{"type":"exit","code":i32}`, exit-chunk-is-last) is the sole fidelity input, never widened: `code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 → `exit-signal(name)` via the **server-side Unix signum→name table** (crate-local; not derived from alktty's `signal_from_name` inverse unless that is public and sound; decide the table's form and record; `core_dumped` **never asserted**, the bool stays false); `−1` → `exit-status(255)`, never a signal claim (rule 3, the SIGHUP collision accepted symmetrically per the mapping.md rationale); non-Unix adds no other negatives (rule 4); the PTY-path rule-5 fidelity limit documented in the module docs (signal- terminated PTY sessions arrive `exit-status(1)`, portable-pty's exit surface, upstream); unmapped negatives → 255 (rule 6). Sent **before** close, from the spawned task. - **`env_request` stays declined** (OQ-09, unchanged from wave 2); `session_cwd`/`session_env` ride `NegotiateRequest` only. - **Contract rows through the harness, per build**, with the wave-2 stub rows (`pty_row_stub`/`shell_row_stub`/ `pipe_row_stub`) replaced; the decline matrix's unset-field rows (feature on + handle unset → the rows decline; featured+set serves through the alkssh-side **TtyBackend shim double**, since alktty's `MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` and so never importable; alkssh hand-rolls the shim, a backend that records `TtyParams`, emits scripted stdout/stderr/exit chunks and consumes stdin/resizes/signals, per the `TtyHandle` field shapes). The identity-landing item (the parked wave-2 note) asserted through the row that observes the identity at `drive_session_pre_negotiated`. - **The real-tool smoke**: one OpenSSH pty session through the harness against the shim (or against a real PTY via a dev-dep `alktty` `local` composition; decide which posture and record; the real bash/`stty size` fidelity was POC stage D's, wave 5 re-lands it as e2e): the smoke owns the wire rows a plain rustsh-client row can't pin (echo paths, `stty size`-grade fidelity are wave-5's matrix; here the pty lifecycle rows only). ## Acceptance Criteria - [x] pty/shell/pipe-exec rows serve through `drive_session_pre_negotiated` (spawned, handler fast); the store-and-take + per-channel PTY state reused from the git task's handoff; pipe-less shell decided + recorded - [x] `pty_request` → `TerminalParamsWire` (ttymodes → the reserved JSON), `shell_request` → shell argv, exec → `wrapper ++ [cmd]`, cwd/env session-side; unset-declines on both OQ-08 rows with recorded wire shapes - [x] The bridge translated per row (stdin chunks + EOF shape verified against alktty; stdout→data; stderr→`extended_data(1)`; resize/signal CTRL_IN reach the serving path through a bounded per-channel surface) - [x] The derivation contract rules 1–6 in code with the signum→name table; `−1`→255; `core_dumped` never; PTY-path rule-5 documented; exit-status before close from the spawned task, pinned by rows - [x] The decline matrix per build green through the harness (unset- field rows included); identity ride asserted; the `env_request` decline unchanged; skeleton stub rows migrated - [x] The real-tool pty smoke row (posture decided + recorded) - [x] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2 (`-D warnings`), fmt, `cargo doc --no-deps` pass ## References - docs/architecture/mapping.md (the `session` channel-type row, request-level rows, §Exit chunk → SSH exit reporting, rules 1–6) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4, spawn-and-return: status before close from the spawned task), docs/architecture/decisions/003-feature-graph.md - docs/architecture/auth.md §Server direction step 4 (the identity ride), §Dispositions (OQ-03) - docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, control rows, the signal/exit fidelity findings, §D.3 mechanics) - docs/research/reverse-runner.md (the pipe-mode pid-only signal caveat, the fidelity note) - alktty 0.8.1: `adapter.rs:234-241` (`drive_session_pre_negotiated`), `negotiation.rs` (`NegotiateRequest`/`TerminalParamsWire`), `wire.rs` (chunks, readers/writers), `control.rs` (`ControlMessage`), `backend.rs` (`TtyHandle`/`TtyParams`/`TtyBackend` shapes) ## Notes ### Decisions recorded (implementation, 2026-10-28) - **The module** (`src/tty_map.rs`, feature `session`): the session rows live in one module following the `git_map` pattern, covering the bridge, the locally-built negotiation, the derivation contract, and the serving path. `serde_json` (the OQ-44 modes JSON + the exit chunk/error-frame reads) and `bytes` (the chunk plane) joined as session-gated optional deps at alktty's own pins. - **The negotiation** (locally built, never wire-derived; OQ-08): `pty_request` args → `TerminalParamsWire` with SSH ttymodes → the **reserved `{}` modes JSON** (alktty OQ-44's reservation, deferred there; the map is honest and nothing is carried). Shell argv rides the serve-config **verbatim**; exec rides `wrapper ++ [cmd]` (never naively split); cwd/env come from `session_cwd`/`session_env` only. `backend_params` stays empty (no backend-specific selector fields on this door; the seam selector is the `backend` string). **Pipe-less shell decided: a `shell_request` without a preceding pty SERVES pipe-mode (`tty = None`)**, because vanilla sshd serves a shell without a terminal too (`ssh -T` is legitimate vanilla). - **Unset-declines (OQ-08), wire shape decided + recorded**: the **serving-path shape (`channel_failure` then close, from a spawned task)** for `shell_argv`-unset (`shell-unconfigured`), `exec_wrapper_argv`-unset (`wrapper-unconfigured`), and the `session_seam`-unset rows (`seam-unconfigured`, the git row's recorded shape); the pty row checks the seam first (a doomed allocation is declined before its params are stored (decline first, alloc never). Field checks precede the seam check. The unvanilla guards keep the git row's shapes: `channel-not-held` → inline request-level failure; `no-identity` → the spawned serving-path decline. A routed request answers `channel_success` inline (the POC-3 §A.5 shape). - **The translation bridge** (the POC-3 §D.1 shape, two adapters around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded mpsc (bound 64) feeding a framing task; SSH channel EOF → the **zero-length stdin-chunk sentinel**, decided and verified against alktty 0.8.1's `pump_client_to_backend`: a zero-length stdin chunk shuts the backend's stdin, identical in effect to `ControlMessage::Eof`, and rides the data path the SSH EOF already chose (no extra control frame). stdout chunks → SSH `data`; stderr chunks → `extended_data(1)`; the exit chunk → the derivation contract. All buffers bounded (convention 9): mpsc 64, duplexes 64 KiB, frames bounds-checked against alktty's `MAX_CHUNK_LEN`. - **The negotiation-error frame is a serving-path decline class** (decided + recorded): alktty re-runs validation/allocation/ownership inline even on the pre-negotiated path and answers failures with a length-prefixed error frame (first byte `0x00`, which is alktty's ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte first-window disambiguation and maps it to `negotiation-failed` + **`exit-status(255)` + close, the one place this door claims 255**: the session never started, nothing ran, so 255 is the honest could-not-start semantic (contrast the git row, whose service runs and honestly reports `1`; `255` is never fabricated for a real outcome). A bridge read ending **without** an exit chunk reports nothing (sole-fidelity-input posture: absence is neither 0 nor 255), and simply closes. - **The control rows**: `window_change_request` → `ControlMessage::Resize`, `signal` → `ControlMessage::Signal`, forwarded through the **per-channel `BridgeSurface`** (handler state while the row serves): a bounded mpsc whose `try_send` is never awaited on the packet loop, so a full/closed surface drops the control event (fire-and-forget rows carry no SSH reply to decline with; bounded resources, convention 9). **Signal names are accepted verbatim, no decline shape**: the backend owns the rejection semantics, nothing on the wire can answer. A pre-serve resize updates the stored pty params instead (the final size wins, matching vanilla sshd's ordering). - **The derivation contract** (mapping.md rules 1–6, in code + unit rows + wire rows): `derive_exit`, where `code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local [`SIGNAL_NAMES`] signum→name table (1–31, server-side work, deliberately not alktty's `signal_from_name` inverse); `−1` → `exit-status(255)` (rule 3, the SIGHUP collision accepted symmetrically); `core_dumped` **never asserted** (the bool stays false on every exit-signal row); unmapped negatives (and `i32::MIN`'s checked-abs failure) → 255 (rule 6); the PTY-path rule-5 fidelity limit documented in the module docs. Report sent **before** close from the spawned task; names ride russh's `Sig` (known variants verbatim, `Sig::Custom` for the rest; RFC 4254 §6.10). - **Cancellation** (bounded resources + alktty's kill-on-drop): the serving path watches two signals: the per-channel cancel (fired by `channel_close` via the surface's watch) and the connection teardown (fired by the handler's `Drop`; the russh session ended however it ended). On either: the serving task **aborts the alktty session task**, dropping its not-yet-resolved `exit_code` future, letting alktty's ADR-056 kill-on-drop contract clean up the cancelled session, then closes the channel and ends. Witnessed live by the shim's kill log row. - **The contract tests** (`tests/tty_mapping.rs`, session-gated; `tests/common/tty_doubles.rs`): the shim double (hand-rolled per the `TtyHandle` field shapes, because alktty's `MockBackend` is `pub(crate)` and never importable) records TtyParams, consumes stdin/EOF/resizes/signals, emits scripted chunks and exit codes, and carries the kill-on-drop witness; the ownership witness double observes the identity at the seam. The wave-2 stub rows migrated: `dispatch_core.rs`'s session-build rows now pin the unset-decline reasons (`seam-unconfigured` / `wrapper-unconfigured`, same serving-path wire shape); `observe.rs`'s two session rows updated (and a one-line pre-existing clippy lint in its git-build cap row fixed). `env_request` decline unchanged (OQ-09). - **The identity ride** (the parked wave-2 item, discharged): asserted through alktty's ADR-050 ownership check, with the shim's `resource_id` guaranteeing the check evaluates, and the ownership double records the resolved identity; the deny shape also pins the negotiation-error wire shape. - **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty session (`ssh -tt … smoke-rider@127.0.0.1` with piped stdin) through a TCP-mounted adapter against a **real PTY via a dev-dep alktty `local` composition**, the decided posture: a shim row cannot show what vanilla OpenSSH really sends, and POC-3 stage D proved exactly this composition. The dev-dep `features = ["local"]` unification applies to the test graph only (the published lib never enables it; `src/` never names a local shape). Requires ssh + sh; absent tooling is skipped **with a printed reason**, never silently green. The smoke pins the pty lifecycle (serve, stdin ride, clean exit → exit-status(0) before close); the `stty size`-grade fidelity matrix is wave 5's. - **Verification**: `cargo test` (default), `cargo test --features session` (incl. the smoke + shim rows), `cargo test --features git`, `cargo test --features tunnel`, `cargo test --all-features`; `cargo clippy --all-targets -- -D warnings` ×5 (default / session / git / tunnel / all-features); `cargo fmt --check`; `cargo doc --no-deps` (default + session + all-features); all green.