diff --git a/Cargo.lock b/Cargo.lock index 9d49d79..792e78b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "uuid", @@ -102,7 +102,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", ] @@ -116,11 +116,14 @@ dependencies = [ "alktty", "alkvault", "async-trait", + "bytes", "gix-hash", "parking_lot", "russh", - "thiserror", + "serde_json", + "thiserror 2.0.21", "tokio", + "tokio-stream", "tracing", "zeroize", ] @@ -137,11 +140,13 @@ dependencies = [ "futures", "futures-core", "libc", + "portable-pty", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-stream", + "tokio-util", "tracing", ] @@ -160,7 +165,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror", + "thiserror 2.0.21", "zeroize", ] @@ -179,6 +184,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "argon2" version = "0.6.0" @@ -432,6 +443,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -661,7 +678,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -729,6 +746,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -889,6 +912,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -2018,6 +2052,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -2026,7 +2072,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", ] @@ -2193,7 +2239,7 @@ dependencies = [ "log", "rand 0.10.3", "sha2 0.11.1", - "thiserror", + "thiserror 2.0.21", "tokio", "windows", "windows-strings", @@ -2351,6 +2397,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2628,7 +2695,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror", + "thiserror 2.0.21", "tokio", "typenum", "universal-hash", @@ -2642,7 +2709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" dependencies = [ "log", - "nix", + "nix 0.31.3", "ssh-encoding", "windows-sys", ] @@ -2797,6 +2864,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2858,6 +2936,22 @@ dependencies = [ "sponge-cursor", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -3037,13 +3131,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3330,6 +3444,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3339,6 +3469,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -3458,6 +3594,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index bc5f6da..a25254d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ name = "alkssh" # the wave-3 config fields; alktunnels still lands with its first # consuming code, w3-tunnel-direct). default = [] -session = ["dep:alktty"] +session = ["dep:alktty", "dep:serde_json", "dep:bytes"] git = ["dep:alkgit"] tunnel = [] sftp = [] @@ -58,6 +58,14 @@ alkvault = "0.1" # touches a real backend; the `local` shapes stay alktty-side. alktty = { version = "0.8.1", default-features = false, optional = true } alkgit = { version = "0.1.0", default-features = false, optional = true } +# session-only: the OQ-44 reserved ttymodes JSON (`TerminalParamsWire.modes`) +# and the exit-chunk / negotiation-error-frame JSON reads in `tty_map.rs`. +# Same pin alktty carries (crate resolution stays single-versioned). +serde_json = { version = "1", optional = true } +# session-only: the alktty chunk plane is `bytes`-based (`Chunk.bytes`) — +# the bridge builds sentinel/ctrl chunks with it. Same pin russh/alktty +# carry (crate resolution stays single-versioned). +bytes = { version = "1", optional = true } tokio = { version = "1.54", features = ["full"] } thiserror = "2.0" tracing = "0.1" @@ -69,6 +77,16 @@ zeroize = "1.9" # parameters and alkgit does not re-export them — test-graph only, # version matched to alkgit's `^0.28` req (same crate in the lockfile). gix-hash = { version = "0.28", features = ["sha1"] } +# The session shim's scripted stdout/stderr streams ride tokio's +# `ReceiverStream` — test-graph only (the crate proper hand-rolls no +# streams). +tokio-stream = "0.1" +# The real-tool pty smoke (`w3-tty-session`): alktty's `local` backend +# composition — the dev-dep `features = ["local"]` unification applies +# to the TEST graph only (feature unification folds `local` into the +# optional alktty dep in test builds; the published lib never enables +# it and `src/` never names a local shape). Same 0.8.1 pin. +alktty = { version = "0.8.1", default-features = false, features = ["local"] } [profile.release] lto = "thin" \ No newline at end of file diff --git a/docs/architecture/mapping.md b/docs/architecture/mapping.md index 4bfdb67..7d5012b 100644 --- a/docs/architecture/mapping.md +++ b/docs/architecture/mapping.md @@ -69,13 +69,13 @@ the served listen flow is implementation-phase contract-test territory.* | SSH request | Family mapping | |---|---| -| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored) | -| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** (no crate default; unset → clean decline, never a fallback — OQ-08 resolved; never wire-supplied) | -| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → clean decline); unknown → `channel_failure`, no resource alloc | +| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored); ttymodes → the reserved modes JSON (OQ-44); a stored pty is consumed by the later `shell_request`/`exec_request` on the channel (`ssh -t host "cmd"`). A pre-serve `window_change_request` resizes the stored params (the final size wins) | +| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline — failure then close; never a fallback — OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)** — vanilla sshd's posture; `ssh -T` is legitimate vanilla | +| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline — failure then close; never a fallback); unknown → `channel_failure`, no resource alloc | | `subsystem_request` | decline (per the rows table; honest, with reason) | | `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships) — wire env is not family config | -| `window_change_request` | resize control (POC-3 §D.1: live resize proven) | -| `signal` | signal control (alktty names verbatim); PTY mode reaches the process group, pipe mode is pid-only (backend semantics — fidelity note, OQ-10) | +| `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget — no SSH reply, so a full/closed control surface drops the event | +| `signal` | signal control (alktty names verbatim — no door-side filter; PTY mode reaches the process group, pipe mode is pid-only — backend semantics, fidelity note, OQ-10); fire-and-forget, no decline shape | | data / `extended_data(1)` | the translation bridge (rows table) | | `exit_status` | from the alktty exit chunk — sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below | | `exit_signal` | negative exit-chunk codes (the Unix pipe path's signal encoding) → `exit-signal(name)` — derivation contract rule 2; `−1` → `exit-status(255)`, never a signal claim (rule 3). PTY path: signal-terminated children arrive as a plain code 1 (portable-pty's exit surface) → `exit-status(1)` — the signal-identity loss is an upstream alktty/portable-pty constraint (rule 5) | diff --git a/src/handler.rs b/src/handler.rs index c236b3d..8c3792f 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -70,7 +70,7 @@ //! capability leak — the POC-3 stage-A rule); the per-length //! `max_exec_command_len` cap is enforced before the parse itself. //! -//! ## The store-and-take channel handoff (`w3-git-mapping`) +//! The store-and-take channel handoff (`w3-git-mapping`) //! //! A confirmed `session` open **stores** its [`Channel`] in //! per-connection handler state ([`Self::take_stored_channel`]'s @@ -83,9 +83,43 @@ //! mutation happens on the loop's task — single-writer, no lock. A //! git exec on a channel whose stored handle is already gone //! (re-served, or never held — unvanilla) declines serving-path. -//! The other session rows currently leave the handle stored until -//! the close releases it (the `w3-tty-session` mapping will take it -//! the same way). +//! +//! ## The session rows' real serving paths (`w3-tty-session`) +//! +//! The pty/shell/pipe rows replaced their wave-3 skeleton stubs with +//! the alktty translation bridge ([`crate::tty_map`]): the pty row +//! stores the negotiated `TerminalParamsWire` ([`Self::ptys`]'s +//! supplier — taken by the later shell/exec request on the same +//! channel, `ssh -t host "cmd"`'s pty-then-exec); the shell and pipe +//! rows take the stored channel the same way the git row does and +//! spawn `serve_tty_row` over the configured seam — locally-built +//! negotiation, unset-fields-decline (OQ-08: `shell_argv` unset → +//! the shell row declines, `exec_wrapper_argv` unset → the pipe row +//! declines, `session_seam` unset → both rows and the pty row +//! decline `seam-unconfigured`; every one of them the serving-path +//! failure+close shape from a spawned task). A routed request +//! answers `channel_success` inline (the request routed; the exit +//! chunk reports the outcome later, status-before-close from the +//! spawned task). +//! +//! ## The control rows' per-channel surface (`w3-tty-session`) +//! +//! `window_change_request` and `signal` arrive on the packet loop +//! **after** the serving task owns the wire — the spawned bridge is +//! reached through [`Self::bridges`]' per-channel surface +//! ([`crate::tty_map::BridgeSurface`]: the bounded control mpsc's +//! `try_send` — never awaited on the packet loop — plus the +//! per-channel cancel). A resize for a channel whose pty is stored +//! but not yet serving updates the stored params (the final size +//! wins); signal names are accepted verbatim (the backend owns the +//! semantics). Unfeatured builds emit the request-level decline +//! event with no reply — the control rows are fire-and-forget, no +//! SSH reply exists to send. [`Self::channel_close`] releases the +//! surface (firing the cancel: the alktty kill-on-drop contract is +//! the cancelled session's cleanup) along with the channel and pty +//! stores. The **connection teardown** signal ([`crate::tty_map:: +//! Teardown`]) fires from this handler's `Drop` so a serving task +//! can never outlive the russh session that spawned it. //! //! ## The spawn-and-return structural constraint (ADR-006 decision 4) //! @@ -94,15 +128,17 @@ //! routing and deadlocks against the peer's follow-up bytes (POC-3 //! proved by construction with receive-pack). Every serving path is //! therefore spawned through [`crate::serve::spawn_serving_path`] and -//! the handler returns fast: the session-row seam stubs clone -//! `session.handle()`, spawn their decline through the clone -//! ([`crate::serve`]), and are gone from the hook's stack — no hook +//! the handler returns fast: the serving rows (the git row's alkgit +//! dispatch, the session rows' config-driven declines and +//! [`crate::tty_map`]'s bridge) all clone `session.handle()`, spawn +//! through the clone, and are gone from the hook's stack — no hook //! awaits a serving path inline. The decline keeps its wire shape //! (failure then close), riding the spawn. Exit/status reporting from //! spawned tasks rides the same cloned handle //! ([`crate::serve::report_exit_status_then_close`] — the mechanics; -//! the derivation rules are wave-3 `mapping.md` material), pinned -//! end-to-end by the mechanics rows in `tests/spawn_return.rs`. +//! the tty rows' derivation rules live in [`crate::tty_map`]), pinned +//! end-to-end by the mechanics rows in `tests/spawn_return.rs` and the +//! mapping rows in `tests/git_mapping.rs` / `tests/tty_mapping.rs`. //! //! The open-level declines (unfeatured/never-feature rows, the tunnel //! seam stub) stay inline by class: they answer the open *before* any @@ -141,16 +177,21 @@ //! session-survives-declines posture) — including the git grammar's //! declinables (`unknown-service` / `service-only` / //! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`); -//! a *serving-path* decline (a routable row that owns the channel: -//! the seam-unconfigured git row and the unvanilla -//! channel-not-held / identity-less guards) answers failure and -//! closes the channel — the vanilla-clean shape for a row that owns -//! the channel. The serving-path decline rides the spawned serving -//! task (ADR-006 decision 4, §The spawn-and-return structural -//! constraint above); the wire shape is unchanged. The real -//! serve's own outcomes are not declines: the substrate maps them -//! (denial band before any protocol byte; exit-status then close — -//! the `exit_code` mapping decision recorded in [`crate::git_map`]). +//! a *serving-path* decline (a routable row that owns the request's +//! whole outcome: the seam-unconfigured git row, the session rows' +//! unset-field and unset-seam declines — `w3-tty-session`'s OQ-08 +//! shapes — and the identity-less guard) answers failure and closes +//! the channel — the vanilla-clean shape for a row whose serving +//! path cannot exist. The serving-path decline rides the spawned +//! serving task (ADR-006 decision 4, §The spawn-and-return +//! structural constraint above); the wire shape is unchanged. The +//! `channel-not-held` guard is the inline request-level shape (the +//! channel that could not be held stays usable). The real serve's +//! own outcomes are not declines: the substrate maps them (denial +//! band before any protocol byte; exit-status then close — the +//! `exit_code` mapping decision recorded in [`crate::git_map`]; +//! the tty bridge's negotiation-error and exit-chunk decisions in +//! [`crate::tty_map`]). //! //! ## The event vocabulary on every decline (`w3-decline-tracing`) //! @@ -199,20 +240,27 @@ use std::sync::Arc; use alkcall::core::auth::Identity; use russh::keys::Algorithm; -use russh::server::{ - Auth, ChannelOpenHandle, Handle, Msg, PublicKeyOffer, Session, VerifiedPublicKey, -}; -use russh::{Channel, ChannelId, Pty}; +#[cfg(any(feature = "git", feature = "session"))] +use russh::server::Handle; +use russh::server::{Auth, ChannelOpenHandle, Msg, PublicKeyOffer, Session, VerifiedPublicKey}; +use russh::{Channel, ChannelId, Pty, Sig}; use crate::channel_cap::ChannelCap; use crate::config::ServeConfig; +#[cfg(feature = "session")] +use crate::config::SessionSeam; use crate::dispatch::{self, ExecRoute}; use crate::error::AdapterError; use crate::fingerprint::fingerprint; #[cfg(feature = "git")] use crate::git_map; use crate::observe; +#[cfg(any(feature = "git", feature = "session"))] use crate::serve; +#[cfg(feature = "session")] +use crate::tty_map; +#[cfg(feature = "session")] +use alktty::negotiation::NegotiateRequest; /// The per-connection handler (one per mounted stream): russh constructs /// nothing here — [`crate::SshAdapter::handle`] builds one per accepted @@ -231,10 +279,39 @@ pub struct SessionHandler { /// The store-and-take handoff (`w3-git-mapping`): confirmed /// session channels held from the open until their row takes or /// the close releases them. Written only by the packet loop's - /// hooks (`channel_open_session` inserts; `exec_request` removes - /// for the git row; `channel_close` removes) — single-writer, no - /// lock. + /// hooks (`channel_open_session` inserts; the git row's and the + /// session rows' serving dispatches remove for their taken + /// channel; `channel_close` removes) — single-writer, no lock. channels: HashMap>, + /// The session rows' negotiated pty params (`w3-tty-session`): + /// `pty_request` stores, the later `shell_request`/`exec_request` + /// on the same channel consumes (`ssh -t host "cmd"`'s + /// pty-then-exec), the close releases. Packet-loop only. + #[cfg(feature = "session")] + ptys: HashMap, + /// The per-channel control surfaces (`w3-tty-session`): installed + /// when a session row's serving task spawns, forwarded to by + /// `window_change_request`/`signal`, fired-and-removed by + /// `channel_close`. Packet-loop only. + #[cfg(feature = "session")] + bridges: HashMap, + /// The connection-teardown signal (`w3-tty-session`): fired by + /// this handler's [`Drop`](Self::drop) so a serving task never + /// outlives the russh session that spawned it (the alktty + /// kill-on-drop contract is the cancelled session's cleanup). + #[cfg(feature = "session")] + teardown: tty_map::Teardown, +} + +#[cfg(feature = "session")] +impl Drop for SessionHandler { + /// Fires the session rows' teardown signal (`w3-tty-session`): + /// every spawned serving task of this connection watches it and + /// cancels its alktty session (the kill-on-drop cleanup) instead + /// of outliving the connection. + fn drop(&mut self) { + self.teardown.fire(); + } } impl SessionHandler { @@ -248,15 +325,22 @@ impl SessionHandler { channel_cap: ChannelCap::default(), resolved_identity: None, channels: HashMap::new(), + #[cfg(feature = "session")] + ptys: HashMap::new(), + #[cfg(feature = "session")] + bridges: HashMap::new(), + #[cfg(feature = "session")] + teardown: tty_map::Teardown::new(), } } /// Takes a stored channel handle (the store half of the - /// store-and-take handoff): the git row's dispatch removes the - /// handle the open stored, handing the stream into the spawned - /// serving task. `None` when nothing is held — a re-served or - /// never-held channel (unvanilla; declined serving-path). - #[cfg(feature = "git")] + /// store-and-take handoff): the git row's and the session rows' + /// dispatches remove the handle the open stored, handing the + /// stream into the spawned serving task. `None` when nothing is + /// held — a re-served or never-held channel (unvanilla; declined + /// serving-path). + #[cfg(any(feature = "git", feature = "session"))] fn take_stored_channel(&mut self, channel: ChannelId) -> Option> { self.channels.remove(&channel) } @@ -343,6 +427,7 @@ impl SessionHandler { /// The session-row gate (`pty`/`shell`/pipe-exec): only the /// `session` feature serves these rows. + #[cfg(feature = "session")] fn serves_session_rows(&self) -> bool { cfg!(feature = "session") } @@ -452,6 +537,13 @@ impl russh::server::Handler for SessionHandler { _session: &mut Session, ) -> Result<(), Self::Error> { self.channels.remove(&channel); + #[cfg(feature = "session")] + { + self.ptys.remove(&channel); + if let Some(surface) = self.bridges.remove(&channel) { + tty_map::fire_cancel(&surface.cancel); + } + } self.channel_cap.release(); Ok(()) } @@ -644,62 +736,220 @@ impl russh::server::Handler for SessionHandler { decline_unfeatured_request(channel, session).await; } ExecRoute::Pipe => { + #[cfg(feature = "session")] if self.serves_session_rows() { - pipe_row_stub(channel, session); - } else { - observe::decline_request( - "exec_request", - "unfeatured", - channel.number(), - Some("pipe"), - ); - decline_unfeatured_request(channel, session).await; + pipe_row_dispatch(self, channel, session, command.to_owned()); + return Ok(()); } + #[cfg(not(feature = "session"))] + let _ = &command; + observe::decline_request( + "exec_request", + "unfeatured", + channel.number(), + Some("pipe"), + ); + decline_unfeatured_request(channel, session).await; } } Ok(()) } - /// The `pty_request` row route (`session`): featured routes to - /// the session-row seam stub; unfeatured declines the capability. + /// The `pty_request` row (`session`): the args → + /// `TerminalParamsWire` (ttymodes → the reserved `{}` JSON, + /// OQ-44), stored per channel for the later shell/exec request on + /// the same channel — after the seam check (a pty whose session + /// can never serve is a declined allocation, + /// `seam-unconfigured`). Unfeatured builds decline the + /// capability. The successful row answers `channel_success` + /// (POC-3 §D.1's shape — serving starts at the shell/exec + /// request). async fn pty_request( &mut self, channel: ChannelId, - _term: &str, - _col_width: u32, - _row_height: u32, - _pix_width: u32, - _pix_height: u32, + term: &str, + col_width: u32, + row_height: u32, + pix_width: u32, + pix_height: u32, _modes: &[(Pty, u32)], session: &mut Session, ) -> Result<(), Self::Error> { - if self.serves_session_rows() { - pty_row_stub(channel, session); - } else { + #[cfg(feature = "session")] + match self.serve.session_seam.clone() { + Some(_) => { + self.ptys.insert( + channel, + tty_map::terminal_params(term, col_width, row_height, pix_width, pix_height), + ); + let _ = session.channel_success(channel); + } + None => { + spawn_seam_stub(channel, session, "pty_request", None, "seam-unconfigured"); + } + } + #[cfg(not(feature = "session"))] + { + let _ = (term, col_width, row_height, pix_width, pix_height); observe::decline_request("pty_request", "unfeatured", channel.number(), None); decline_unfeatured_request(channel, session).await; } Ok(()) } - /// The `shell_request` row route (`session`): the same shape as - /// `pty_request`. The OQ-08 unset-shell check is the serving - /// path's consumption (wave 3): this wave the stub declines the - /// row regardless, so the unset-declines posture already holds. + /// The `shell_request` row (`session`): the serve-config shell + /// argv, verbatim, as `NegotiateRequest.cmd`; the stored pty + /// params consumed (`None` → pipe-mode shell — decided: a pty-less + /// shell request is served, `ssh -T host` is legitimate vanilla); + /// unset-declines first (OQ-08: `shell_argv` unset → + /// `shell-unconfigured`; the seam unset → `seam-unconfigured` + /// — both the spawned serving-path shape). A routed request + /// answers `channel_success` inline; the exit chunk reports the + /// outcome, status-before-close from the spawned task. async fn shell_request( &mut self, channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { - if self.serves_session_rows() { - shell_row_stub(channel, session); - } else { + #[cfg(feature = "session")] + match ( + self.serve.shell_argv.clone(), + self.serve.session_seam.clone(), + ) { + (None, _) => { + spawn_seam_stub( + channel, + session, + "shell_request", + None, + "shell-unconfigured", + ); + } + (_, None) => { + spawn_seam_stub(channel, session, "shell_request", None, "seam-unconfigured"); + } + (Some(shell_argv), Some(seam)) => { + let tty = self.ptys.remove(&channel); + let Some(taken) = self.take_stored_channel(channel) else { + observe::decline_request( + "shell_request", + "channel-not-held", + channel.number(), + None, + ); + let _ = session.channel_failure(channel); + return Ok(()); + }; + let request = tty_map::negotiate_request( + seam.backend.clone(), + tty, + shell_argv, + self.serve.session_cwd.clone(), + self.serve.session_env.clone(), + ); + spawn_tty_row( + self, + channel, + session, + "shell_request", + None, + taken, + request, + seam, + ); + } + } + #[cfg(not(feature = "session"))] + { observe::decline_request("shell_request", "unfeatured", channel.number(), None); decline_unfeatured_request(channel, session).await; } Ok(()) } + /// `window_change_request` (`session`): the resize control row — + /// forwarded into the serving bridge's bounded control surface + /// (`try_send`, never awaited on the packet loop); for a channel + /// whose pty is stored but not yet serving, the stored params + /// resize instead (the final size wins — vanilla sshd's own + /// ordering); with neither, the event is dropped. Fire-and-forget + /// on the wire — no SSH reply exists, so a decline sends none + /// (the event is the record). Signal names: see [`Self::signal`]. + async fn window_change_request( + &mut self, + channel: ChannelId, + col_width: u32, + row_height: u32, + pix_width: u32, + pix_height: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + #[cfg(feature = "session")] + { + let cols = tty_map::clamp_u16(col_width); + let rows = tty_map::clamp_u16(row_height); + let pix_w = tty_map::clamp_u16(pix_width); + let pix_h = tty_map::clamp_u16(pix_height); + if let Some(surface) = self.bridges.get(&channel) { + let message = alktty::ControlMessage::Resize { + cols, + rows, + pixel_width: pix_w, + pixel_height: pix_h, + }; + let _ = surface.forward_control(&message); + } else if let Some(params) = self.ptys.get_mut(&channel) { + params.cols = cols; + params.rows = rows; + params.pixel_width = pix_w; + params.pixel_height = pix_h; + params.modes = serde_json::json!({}); + } + } + #[cfg(not(feature = "session"))] + { + let _ = (col_width, row_height, pix_width, pix_height); + observe::decline_request( + "window_change_request", + "unfeatured", + channel.number(), + None, + ); + } + Ok(()) + } + + /// The `signal` row (`session`): accepted verbatim — no + /// alkssh-side name filter (the backend owns the semantics; + /// alktty's control handle applies its own default kill for + /// names it rejects, and the pipe-mode pid-only vs PTY + /// process-group scope is backend semantics) — so there is no + /// decline shape: the request is fire-and-forget on the SSH + /// wire, and the bridge's bounded surface is the only path. An + /// unfeatured build emits the decline event with no reply. + async fn signal( + &mut self, + channel: ChannelId, + signal: Sig, + _session: &mut Session, + ) -> Result<(), Self::Error> { + #[cfg(feature = "session")] + { + if let Some(surface) = self.bridges.get(&channel) { + let message = alktty::ControlMessage::Signal { + name: tty_map::sig_control_name(&signal), + }; + let _ = surface.forward_control(&message); + } + } + #[cfg(not(feature = "session"))] + { + let _ = signal; + observe::decline_request("signal", "unfeatured", channel.number(), None); + } + Ok(()) + } + /// Every subsystem name declines (unimplemented on this door): /// `subsystem("sftp")` is the OQ-13 placeholder — the feature /// exists, the route does not until alkfs lands; git service @@ -823,21 +1073,22 @@ async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) { let _ = session.channel_failure(channel); } -/// The family-seam decline shape for a **routed-but-not-yet-served** -/// row — now the spawned serving task's body: fail the request and -/// close the channel, through the cloned handle the task was handed. -/// The vanilla-clean shape for a serving path that owns the channel -/// (wave 3 replaces these stubs with the real serving paths; a -/// declined seam never leaks a half-served channel, since the decline -/// IS the row this wave). Decline-first-alloc-never holds structurally: -/// nothing is allocated before the failure lands — the spawn's handle -/// clone is the only resource ever taken. +/// The spawned serving-path decline body (`w3-git-mapping`'s shape, +/// reused by `w3-tty-session`'s config-driven declines): fail the +/// request and close the channel, through the cloned handle the task +/// was handed — the vanilla-clean shape for a routed row that owns +/// the request's whole outcome. The helper name keeps the wave-3 +/// history (the decline IS the path it spawns). Decline-first-alloc- +/// never holds structurally: nothing is allocated before the failure +/// lands — the spawn's handle clone is the only resource ever taken. /// /// The serving-path lifecycle events ride this body /// ([`crate::observe`]): started (the task's first action), the -/// serving-path decline (class `serving`, reason `seam-stub`), and — -/// after the wire reporting has completed — ended, or failed when a -/// report send could not complete (the one warn in the vocabulary). +/// serving-path decline (class `serving`, the caller's vocabulary +/// reason), and — after the wire reporting has completed — ended, or +/// failed when a report send could not complete (the one warn in the +/// vocabulary). +#[cfg(any(feature = "git", feature = "session"))] async fn seam_stub_decline( handle: Handle, channel: ChannelId, @@ -862,6 +1113,7 @@ async fn seam_stub_decline( /// task, and the hook returns — no serving path awaits inline. The /// row never serves, so the decline *is* the path: failure + close, /// event-shaped ([`crate::observe`]) with the caller's reason. +#[cfg(any(feature = "git", feature = "session"))] fn spawn_seam_stub( channel: ChannelId, session: &mut Session, @@ -929,28 +1181,109 @@ fn git_row_dispatch( let _ = session.channel_success(channel); } -/// The session pipe row's seam stub — wave 3 lands the alktty pipe -/// seam here (`wrapper ++ [cmd]`, the OQ-08 argv append), spawned per -/// ADR-006 decision 4; the skeleton declines at the seam, on its own -/// task. -fn pipe_row_stub(channel: ChannelId, session: &mut Session) { - spawn_seam_stub(channel, session, "exec_request", Some("pipe"), "seam-stub"); +/// The session pipe row's dispatch (`w3-tty-session`): unset-declines +/// first (OQ-08 — `exec_wrapper_argv` unset → `wrapper-unconfigured`; +/// the seam unset → `seam-unconfigured`; both the spawned serving-path +/// shape, never a fallback), then the store-and-take handoff +/// (`channel-not-held` → the inline unvanilla decline, the git row's +/// shape), the wire command appended to the wrapper argv (`wrapper ++ +/// [cmd]` — never naively split), the stored pty params consumed if a +/// pty preceded, and the spawned bridge. A routed exec answers +/// `channel_success` inline (the request routed; the exit chunk +/// reports the serving outcome later). +#[cfg(feature = "session")] +fn pipe_row_dispatch( + handler: &mut SessionHandler, + channel: ChannelId, + session: &mut Session, + command: String, +) { + let Some(mut cmd) = handler.serve.exec_wrapper_argv.clone() else { + spawn_seam_stub( + channel, + session, + "exec_request", + Some("pipe"), + "wrapper-unconfigured", + ); + return; + }; + let Some(seam) = handler.serve.session_seam.clone() else { + spawn_seam_stub( + channel, + session, + "exec_request", + Some("pipe"), + "seam-unconfigured", + ); + return; + }; + let tty = handler.ptys.remove(&channel); + let Some(taken) = handler.take_stored_channel(channel) else { + observe::decline_request( + "exec_request", + "channel-not-held", + channel.number(), + Some("pipe"), + ); + let _ = session.channel_failure(channel); + return; + }; + cmd.push(command); + let request = tty_map::negotiate_request( + seam.backend.clone(), + tty, + cmd, + handler.serve.session_cwd.clone(), + handler.serve.session_env.clone(), + ); + spawn_tty_row( + handler, + channel, + session, + "exec_request", + Some("pipe"), + taken, + request, + seam, + ); } -/// The session pty row's seam stub — wave 3 lands the alktty -/// `NegotiateRequest.tty` construction here -/// (`drive_session_pre_negotiated`), spawned per ADR-006 decision 4; -/// the skeleton declines at the seam, on its own task. -fn pty_row_stub(channel: ChannelId, session: &mut Session) { - spawn_seam_stub(channel, session, "pty_request", None, "seam-stub"); -} - -/// The session shell row's seam stub — wave 3 lands the alktty -/// `NegotiateRequest.cmd` = serve-config shell argv here (OQ-08, no -/// crate default), spawned per ADR-006 decision 4; the skeleton -/// declines at the seam, on its own task. -fn shell_row_stub(channel: ChannelId, session: &mut Session) { - spawn_seam_stub(channel, session, "shell_request", None, "seam-stub"); +/// Installs the per-channel bridge surface, clones the session +/// handle, and spawns the spawned serving path — the session rows' +/// spawn step (ADR-006 decision 4: the handler returns fast; the +/// routed request answers `channel_success` inline). +#[cfg(feature = "session")] +#[allow(clippy::too_many_arguments)] +fn spawn_tty_row( + handler: &mut SessionHandler, + channel: ChannelId, + session: &mut Session, + row: &'static str, + exec_route: Option<&'static str>, + taken: Channel, + request: NegotiateRequest, + seam: SessionSeam, +) { + let (surface, chunk_rx, cancel_rx) = tty_map::build_surface(); + let stdin_tx = surface.sender.clone(); + handler.bridges.insert(channel, surface); + let identity = handler.resolved_identity.clone(); + let handle = session.handle(); + serve::spawn_serving_path(tty_map::serve_tty_row( + row, + exec_route, + handle, + taken, + request, + identity, + seam, + stdin_tx, + chunk_rx, + cancel_rx, + handler.teardown.subscribe(), + )); + let _ = session.channel_success(channel); } /// The tunnel row's seam stub: the explicit `ConnectFailed` reject diff --git a/src/lib.rs b/src/lib.rs index 0c62e62..2ef719a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ mod host_key; mod key_source; mod observe; mod serve; +#[cfg(feature = "session")] +mod tty_map; pub use adapter::SshAdapter; #[cfg(feature = "git")] diff --git a/src/observe.rs b/src/observe.rs index a1095e5..ff5d2cf 100644 --- a/src/observe.rs +++ b/src/observe.rs @@ -65,9 +65,15 @@ //! classifications: `"unfeatured"` (feature-gate absence), //! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`, //! `"connect-failed"` (the tunnel seam stub's `ConnectFailed`), -//! `"seam-stub"` (the session-row seam stubs), -//! `"seam-unconfigured"` (the config-driven git-row decline — -//! feature on, [`crate::GitSeam`] unset; `w3-git-mapping`), +//! `"seam-unconfigured"` (the config-driven row decline — the git +//! row and, now, the session rows: feature on, seam unset; +//! `w3-git-mapping` / `w3-tty-session`), +//! `"shell-unconfigured"` / `"wrapper-unconfigured"` (`w3-tty- +//! session`: the OQ-08 unset-field rows — feature on, the +//! serve-config field unset, never a fallback), +//! `"negotiation-failed"` (`w3-tty-session`: the alktty +//! negotiation error frame the bridge received — the session never +//! started; the frame's JSON detail stays alktty-side), //! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"` //! (the exec grammar's git declinables — the parse core's two and //! the git row's validation gate; no service token or repo string @@ -210,8 +216,9 @@ pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) { /// A serving-path decline: a routed row whose serving task answers /// failure-then-close rather than serving. This wave that is the seam /// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3 -/// mappings emit it from their real serving tasks wherever serving +/// mappings emit it from their real spawned paths wherever serving /// itself cannot proceed — the class the spawned paths inherit. +#[cfg(any(feature = "git", feature = "session"))] pub(crate) fn decline_serving( row: &'static str, reason: &'static str, @@ -232,6 +239,7 @@ pub(crate) fn decline_serving( /// Spawned-path started — emitted as the spawned task's first action, /// so "started" is the task's own honest start, not the hook's spawn /// call. The mapping tasks' serving paths inherit this shape. +#[cfg(any(feature = "git", feature = "session"))] pub(crate) fn serving_path_started( row: &'static str, channel_id: u32, @@ -249,6 +257,7 @@ pub(crate) fn serving_path_started( /// Spawned-path ended — the serving future finished (cleanly, or with /// the decline it was wired to answer); emitted *after* the path's /// wire reporting has completed. +#[cfg(any(feature = "git", feature = "session"))] pub(crate) fn serving_path_ended( row: &'static str, channel_id: u32, @@ -269,6 +278,7 @@ pub(crate) fn serving_path_ended( /// the family's recoverable-failure level. Only russh protocol errors /// ride the `error` field (§Safety posture); the mapping tasks emit /// this from their real paths when serving fails before reporting. +#[cfg(any(feature = "git", feature = "session"))] pub(crate) fn serving_path_failed( row: &'static str, channel_id: u32, @@ -292,7 +302,9 @@ mod tests { /// A fake error shape — the failure row only pins that a Debug /// error value rides the `error` field. #[derive(Debug)] + #[cfg(any(feature = "git", feature = "session"))] struct Probe; + #[cfg(any(feature = "git", feature = "session"))] static PROBE: Probe = Probe; use super::*; @@ -420,6 +432,7 @@ mod tests { /// - *Contract stamp:* the `exec_route` field — present on the /// exec row, absent elsewhere (a `None` field is not recorded /// by `tracing`). The pty/shell lifecycle rows carry no route. + #[cfg(any(feature = "git", feature = "session"))] #[test] fn serving_path_lifecycle_carries_started_ended_and_the_warn_failure() { let fields = capture(|| serving_path_started("pty_request", 1, None)); diff --git a/src/tty_map.rs b/src/tty_map.rs new file mode 100644 index 0000000..3fe9019 --- /dev/null +++ b/src/tty_map.rs @@ -0,0 +1,895 @@ +//! The session mapping (`w3-tty-session`) — the biggest single-row +//! surface of the spine ([`crate::dispatch::ExecRoute::Pipe`] + the +//! `pty_request`/`shell_request` rows): pty/shell/pipe-exec serve +//! through the published alktty seam +//! (`drive_session_pre_negotiated(writer, reader, req, backends, +//! ownership, identity)`) over a **translation bridge, not a byte +//! pump** (POC-3 §D.1): alktty's data plane is chunk-framed both +//! directions (`alktty::wire` — 5-byte header + payload), the SSH +//! channel is a plain byte plane, so the bridge wraps the channel +//! with small adapters around a pair of in-memory duplexes. +//! +//! ## The negotiation is built locally, never wire-derived (OQ-08) +//! +//! The serve-config is the whole truth: +//! +//! - `pty_request` args → `alktty::negotiation::TerminalParamsWire` +//! (`term`/`cols`/`rows`/pixel dims; SSH ttymodes → the **reserved +//! modes JSON `{}`** — alktty OQ-44's reservation: backends must +//! ignore modes in v1, so the door writes `{}` and defers +//! faithfully). Stored per channel (the POC-3 accepted-but-held +//! shape, [`crate::handler`]'s store) and consumed by the later +//! `shell_request`/`exec_request` on the same channel +//! (`ssh -t host "cmd"` sends pty-then-exec). +//! - `shell_request` → `NegotiateRequest{carriage: "raw", backend: +//! the seam selector, tty: the consumed pty params, cmd: the +//! serve-config shell argv **verbatim**, cwd: session_cwd, env: +//! session_env}` — the wire never carries command, args, cwd or +//! env (OQ-08/OQ-09; the `env_request` row stays declined +//! unconditionally). **A `shell_request` without a preceding pty +//! serves pipe-mode (`tty = None`)** — decided: vanilla sshd serves +//! a shell without a terminal too (`ssh -T host` is legitimate +//! vanilla), so a pty-less shell request is served, never declined. +//! - `exec_request` (pipe row) → `cmd = exec_wrapper_argv ++ [cmd]` +//! (the OQ-08 argv append — vanilla quoting semantics; the wire +//! command string is **never naively split**), `tty = +//! Some(consumed pty)` when a pty preceded, else `None` (pipe +//! mode). +//! - **Unset-declines** (OQ-08, never a fallback): `shell_argv` +//! unset → `shell_request` declines; `exec_wrapper_argv` unset → +//! the pipe row declines. Wire shape (decided + recorded): the +//! **serving-path shape — `channel_failure` then channel close, +//! from a spawned task** ([`crate::serve`]'s mechanics; the same +//! spawned-stub body the git row uses) — the class of a routable +//! row that owns the request's whole outcome; the session channel +//! closes the way it would when the request itself cannot exist. +//! `session_seam` unset (feature on, seam absent) → the same +//! shape, reason `seam-unconfigured` (the decline matrix's +//! config-driven note); `pty_request` checks the seam too — a pty +//! whose session can never serve is a doomed allocation. Field +//! checks precede the seam check (the row's own unset field is the +//! first decline); the decline never takes the channel and never +//! consumes stored pty state. +//! - The unvanilla guards keep the git row's shapes: `channel-not- +//! held` (a routed row whose channel handle is already gone) +//! answers request-level failure inline; `no-identity` (post-auth +//! unreachable, fail-closed) is the spawned serving-path decline. +//! +//! ## The translation bridge +//! +//! Two small adapters around a duplex pair the serving task drives +//! (the POC-3 §D.1 shape, re-landed as contract): +//! +//! - **SSH → chunks** (the `client_recv` side of alktty's session): +//! a bounded [`tokio::sync::mpsc`] (the +//! [`CONTROL_SURFACE_BOUND`] bound) feeds a framing task that +//! writes `alktty::wire::Chunk`s into the duplex — the same mpsc +//! the control rows ride ([`BridgeSurface`], §below). SSH `data` → +//! `Chunk::stdin`; SSH channel EOF → **the zero-length +//! stdin-chunk sentinel** — decided (verified against alktty 0.8.1's +//! adapter pump, `adapter.rs::pump_client_to_backend`): a +//! zero-length stdin chunk shuts the backend's stdin, *identical +//! in effect* to `ControlMessage::Eof` on `STREAM_CTRL_IN`; the +//! sentinel rides the data path the SSH EOF already chose, so no +//! extra control frame is sent. Bounded end-to-end: the mpsc is +//! bounded, the duplexes bounded, and russh's per-channel window +//! bounds the SSH side. +//! - **Chunks → SSH**: the `client_send` side of alktty's session is +//! read through a **4-byte first-window disambiguation** (alktty's +//! own framing-disambiguation invariant, ADR-052 §5: a negotiation +//! *error frame* is length-prefixed JSON whose first length byte +//! is `0x00`; a raw chunk's first byte is a `stream_type` ≤ 4). +//! stdout chunks → SSH `data`; stderr chunks → SSH +//! `extended_data(1)` (PTY mode merges upstream — the backend's +//! stderr stream is `None` there, so the translation is universal: +//! no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) → +//! the derivation contract (§below). No unbounded buffering in +//! either adapter (AGENTS.md convention 9). +//! - **The negotiation-error frame is the bridge's serving-path +//! decline class**: alktty re-runs validation + allocation inline +//! even on the pre-negotiated path (carriage/cmd/backend lookup/ +//! ownership), and a failure answers a length-prefixed error +//! frame instead of chunks. Decided + recorded: the bridge maps +//! that frame to a serving-path decline event +//! (`negotiation-failed`) and an **`exit-status(255)` then close** +//! — the one place this door claims 255: the session **never +//! started** (no exit chunk exists, nothing ran, so 255 is the +//! honest could-not-start semantic — contrast the git row, whose +//! service *runs* its substrate and honestly reports `1`). The +//! error frame's JSON detail stays alktty-side; only the +//! vocabulary value rides the event. +//! - A bridge read that ends **without** an exit chunk reports +//! nothing (the chunk is the sole fidelity input; its absence is +//! neither 0 nor 255 — no fabricated report): plain close, and +//! the lifecycle's ended event. +//! +//! ## The control rows +//! +//! The requests arrive on the packet loop after the serving task +//! owns the wire, and reach the spawned bridge through the +//! **per-channel control surface** ([`BridgeSurface`] — handler +//! state while the row serves): a bounded mpsc whose send half +//! forward_control `try_send`s the control chunk (never awaited on +//! the packet loop). `window_change_request` → +//! `ControlMessage::Resize`; `signal` → `ControlMessage::Signal` +//! (alktty's uppercase-name vocabulary). Decisions, recorded: +//! the SSH control rows are fire-and-forget (no reply exists on +//! the wire), so **a full or closed surface drops the control +//! event** — the bound keeps a stalled bridge from stalling the +//! packet loop (bounded resources, convention 9); **signal names +//! are accepted verbatim** — no alkssh-side name filter: the +//! backend owns the rejection semantics (alktty passes the name to +//! the backend's control handle, whose semantics decide), and the +//! pipe-mode pid-only vs PTY process-group *scope* is backend +//! semantics (the mapping row note), so there is **no decline +//! shape** for a name — nothing on the wire can answer. A resize +//! arriving for a channel whose pty is stored but not yet serving +//! updates the stored params instead (the final size wins — +//! vanilla sshd's own ordering); a resize with neither a serving +//! bridge nor a stored pty is dropped. +//! +//! ## The exit derivation contract (mapping.md §Exit chunk, rules +//! 1–6 — normative) +//! +//! The alktty exit chunk (`{"type":"exit","code":i32}`, +//! exit-chunk-is-last per alktty's ADR-005 lineage) is the sole +//! fidelity input, **never widened**: [`derive_exit`] maps it to +//! exactly one SSH exit report shape: +//! +//! 1. `code ≥ 0` → `exit-status(N)` (rule 1). +//! 2. negative `−N`, N ≥ 2 → `exit-signal(name)` via the +//! crate-local **Unix signum→name table** ([`SIGNAL_NAMES`]). +//! Server-side work — the signal semantics belong to the serving +//! platform where the backend reaps. Deliberately *not* derived +//! from alktty's `signal_from_name` inverse, which is a forward +//! name→number map for the control row, not a derivation table. +//! `core_dumped` is **never asserted** — alktty discards the +//! core-dump bit entirely, so nothing better is knowable from the +//! chunk, and SSH's `exit-signal` bool stays false on every row. +//! 3. `−1` → `exit-status(255)`, never a signal claim (rule 3 — the +//! could-not-determine sentinel collides with the SIGHUP +//! encoding on the pipe path, accepted symmetrically per the +//! mapping: neither a fabricated signal for a wait failure nor a +//! real one). +//! 4. Non-Unix adds no other negatives (rule 4): the table above is +//! the only negative recognizer. +//! 5. **The PTY-path fidelity limit (rule 5, upstream)**: +//! portable-pty 0.9 reaps a signal-terminated Unix child as a +//! plain code (the signal number is unrecoverable through its +//! trait surface), so signal-terminated PTY sessions arrive as +//! `exit-status(1)` — a documented alktty/portable-pty ask; this +//! module's table in practice sees the *pipe* path's negative +//! encoding only. Faithful `exit-signal` there needs the alktty +//! backend to conform to its own ADR's negative-code convention; +//! alkssh's contract adapts automatically. +//! 6. Unmapped negatives → rule 3 (`exit-status(255)`) — the +//! mapping never invents a name (defensive case: the +//! convention's producers are `−1` and `−signum` only). +//! +//! The report is sent **before** close, from the spawned serving +//! task (ADR-006 decision 4, via the cloned `Session::handle()`). +//! +//! ## Cancellation (bounded resources + alktty's kill-on-drop) +//! +//! The serving task watches two signals: the **per-channel cancel** +//! (fired by `channel_close`, or surfaced by the surface's drop) and +//! the **connection teardown** ([`Teardown`] — fired by the +//! handler's `Drop`; the russh session ended, however it ended). On +//! either, the task aborts the alktty session task — dropping its +//! not-yet-resolved `exit_code` future, which is alktty's +//! kill-on-drop contract firing (its ADR-056) — then closes the SSH +//! channel and ends. Nothing is fabricated on a cancel. The +//! detached stdin/framing tasks are bounded by their duplex/mpsc/ +//! russh-window bounds and end on the same signals' effects. + +use alkcall::core::auth::Identity; +use alktty::ControlMessage; +use alktty::negotiation::{NegotiateRequest, TerminalParamsWire}; +use alktty::wire::{ + Chunk, ChunkWriter, MAX_CHUNK_LEN, STREAM_CTRL_IN, STREAM_CTRL_OUT, STREAM_STDERR, + STREAM_STDIN, STREAM_STDOUT, +}; +use bytes::Bytes; +use russh::ChannelId; +use russh::server::Handle; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, watch}; + +use crate::config::SessionSeam; +use crate::observe; + +/// The negotiation frame the bridge speaks locally — every locally +/// constructed request reads `"raw"` (alktty's v1 carriage). +pub(crate) const CARRIAGE: &str = "raw"; + +/// The per-channel control surface's bound: control rows are rare +/// (resize on window drag, signal), so the bound never bites vanilla +/// traffic while keeping the forwarding surface bounded +/// (§the control rows). The shim's scripted rows exercise a handful. +pub(crate) const CONTROL_SURFACE_BOUND: usize = 64; + +/// The translation bridge duplex capacity — the wave-2 loopback +/// harness's per-direction convention, kept for the bridge pairs. +const BRIDGE_DUPLEX_CAPACITY: usize = 65_536; + +/// The stdin pump's read buffer — the SSH-side chunk granularity +/// (per-chunk payloads are bounded in practice by russh's +/// `maximum_packet_size`, here by [`MAX_CHUNK_LEN`] on the frame). +const STDIN_PUMP_BUF: usize = 16 * 1024; + +/// One SSH exit report shape derived from an exit chunk — +/// [`derive_exit`]'s outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExitReport { + /// `exit-status(N)` — codes ≥ 0, the `−1` sentinel, and every + /// unmapped negative (rules 1, 3, 4, 6). + Status(u32), + /// `exit-signal(name)` — a mapped negative `−N`, N ≥ 2 (rule 2). + /// The name is the table's value, never an invention. + Signal(&'static str), +} + +/// The crate-local Unix signum→name table (rule 2): the 1–31 +/// conventional Unix signals, as the exit chunk's producers encode +/// them (`−signum`). Server-side work — the serving platform owns +/// the signal semantics where the backend reaps. Deliberately **not** +/// derived from alktty's `signal_from_name` (a forward name→number +/// map for the control row, not a derivation table; deliberately +/// narrower too — it exists for what a front-end sends, not for what +/// a child can die by). +pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[ + (1, "HUP"), + (2, "INT"), + (3, "QUIT"), + (4, "ILL"), + (5, "TRAP"), + (6, "ABRT"), + (7, "BUS"), + (8, "FPE"), + (9, "KILL"), + (10, "USR1"), + (11, "SEGV"), + (12, "USR2"), + (13, "PIPE"), + (14, "ALRM"), + (15, "TERM"), + (16, "STKFLT"), + (17, "CHLD"), + (18, "CONT"), + (19, "STOP"), + (20, "TSTP"), + (21, "TTIN"), + (22, "TTOU"), + (23, "URG"), + (24, "XCPU"), + (25, "XFSZ"), + (26, "VTALRM"), + (27, "PROF"), + (28, "WINCH"), + (29, "IO"), + (30, "PWR"), + (31, "SYS"), +]; + +/// The derivation contract's whole decision procedure (§module docs, +/// mapping.md rules 1–6 — normative). +pub(crate) fn derive_exit(code: i32) -> ExitReport { + match code { + code if code >= 0 => ExitReport::Status(code as u32), + -1 => ExitReport::Status(255), + other => match other.checked_abs() { + Some(signum) => match SIGNAL_NAMES.iter().find(|(n, _)| *n == signum as u32) { + Some((_, name)) => ExitReport::Signal(name), + None => ExitReport::Status(255), + }, + None => ExitReport::Status(255), + }, + } +} + +/// The table's name → russh's `Sig` vocabulary (the +/// `exit_signal_request` parameter). russh's known set rides its +/// variant names; anything else rides `Sig::Custom` (RFC 4254 §6.10's +/// signal-name encoding — vanilla clients parse arbitrary names). +pub(crate) fn sig_from_name(name: &str) -> russh::Sig { + match name { + "ABRT" => russh::Sig::ABRT, + "ALRM" => russh::Sig::ALRM, + "FPE" => russh::Sig::FPE, + "HUP" => russh::Sig::HUP, + "ILL" => russh::Sig::ILL, + "INT" => russh::Sig::INT, + "KILL" => russh::Sig::KILL, + "PIPE" => russh::Sig::PIPE, + "QUIT" => russh::Sig::QUIT, + "SEGV" => russh::Sig::SEGV, + "TERM" => russh::Sig::TERM, + "USR1" => russh::Sig::USR1, + other => russh::Sig::Custom(other.to_owned()), + } +} + +/// The SSH signal row's name translation: russh's `Sig` → the name +/// string alktty's control vocabulary carries (the uppercase names; +/// `Custom` carrying its own). Names flow to the backend verbatim — +/// no alkssh-side filter (§the control rows). +pub(crate) fn sig_control_name(sig: &russh::Sig) -> String { + match sig { + russh::Sig::ABRT => "ABRT".to_owned(), + russh::Sig::ALRM => "ALRM".to_owned(), + russh::Sig::FPE => "FPE".to_owned(), + russh::Sig::HUP => "HUP".to_owned(), + russh::Sig::ILL => "ILL".to_owned(), + russh::Sig::INT => "INT".to_owned(), + russh::Sig::KILL => "KILL".to_owned(), + russh::Sig::PIPE => "PIPE".to_owned(), + russh::Sig::QUIT => "QUIT".to_owned(), + russh::Sig::SEGV => "SEGV".to_owned(), + russh::Sig::TERM => "TERM".to_owned(), + russh::Sig::USR1 => "USR1".to_owned(), + russh::Sig::Custom(name) => name.clone(), + } +} + +/// The session dimension clamp: u32 wire dims → the wire's u16 (a +/// dimension beyond 65535 is nonsense on both papers; the cap, not +/// a truncation of useful data). +pub(crate) fn clamp_u16(value: u32) -> u16 { + value.min(u16::MAX as u32) as u16 +} + +/// The `pty_request` args → `TerminalParamsWire` conversion (§the +/// negotiation section): SSH ttymodes → the **reserved modes JSON +/// `{}`** (alktty OQ-44's reservation — the map is honest: nothing +/// is carried, exactly what the reservation asks). +pub(crate) fn terminal_params( + term: &str, + cols: u32, + rows: u32, + pix_width: u32, + pix_height: u32, +) -> TerminalParamsWire { + TerminalParamsWire { + term: Some(term.to_owned()), + cols: clamp_u16(cols), + rows: clamp_u16(rows), + pixel_width: clamp_u16(pix_width), + pixel_height: clamp_u16(pix_height), + modes: serde_json::json!({}), + } +} + +/// Builds the locally-constructed `NegotiateRequest` (§the +/// negotiation section): the serve-config's values verbatim; the +/// reserved backend_params empty (the seam selector is the `backend` +/// string; no backend-specific selector fields exist on this door). +pub(crate) fn negotiate_request( + seam_backend: String, + tty: Option, + cmd: Vec, + cwd: Option, + env: std::collections::BTreeMap, +) -> NegotiateRequest { + NegotiateRequest { + carriage: CARRIAGE.to_owned(), + backend: seam_backend, + tty, + cmd, + cwd, + env: env.into_iter().collect(), + backend_params: Default::default(), + } +} + +/// One parsed bridge-side frame from alktty's `client_send` half — +/// the 4-byte first-window disambiguation's outcome (§the +/// translation bridge). +enum BridgeFrame { + /// A raw alktty chunk (data or server→client control). + Chunk(Chunk), + /// The length-prefixed negotiation error frame — the session + /// failed validation/allocation/ownership at the seam. The bytes + /// are alktty-side detail (never read here — only the + /// vocabulary value rides the decline event). + NegotiationError, + /// The half ended (alktty's task completed or was cancelled) + /// with no exit chunk having been observed. + Closed, + /// The half broke (a non-EOF I/O error, or a rogue frame). + Break, +} + +/// Reads one bridge frame — the disambiguation invariant: the first +/// byte `0x00` is a negotiation error frame's length-prefix high +/// byte; anything ≤ 4 is a chunk `stream_type`. Bounds-checked +/// against [`MAX_CHUNK_LEN`] both directions — a rogue length never +/// triggers an oversized read. +async fn read_bridge_frame(reader: &mut (impl tokio::io::AsyncRead + Unpin)) -> BridgeFrame { + let mut head = [0u8; 4]; + match reader.read_exact(&mut head).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return BridgeFrame::Closed, + Err(_) => return BridgeFrame::Break, + } + if head[0] == 0x00 { + let length = u32::from_be_bytes([0, head[1], head[2], head[3]]); + if length as usize > MAX_CHUNK_LEN as usize { + return BridgeFrame::Break; + } + let mut body = vec![0u8; length as usize]; + return match reader.read_exact(&mut body).await { + Ok(_) => BridgeFrame::NegotiationError, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => BridgeFrame::Closed, + Err(_) => BridgeFrame::Break, + }; + } + let stream_type = head[0]; + let mut last = [0u8; 1]; + match reader.read_exact(&mut last).await { + Ok(_) => {} + Err(_) => return BridgeFrame::Break, + } + let length = u32::from_be_bytes([head[1], head[2], head[3], last[0]]); + if length as usize > MAX_CHUNK_LEN as usize { + return BridgeFrame::Break; + } + let mut body = vec![0u8; length as usize]; + match reader.read_exact(&mut body).await { + Ok(_) => BridgeFrame::Chunk(Chunk { + stream_type, + bytes: Bytes::from(body), + }), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => BridgeFrame::Closed, + Err(_) => BridgeFrame::Break, + } +} + +/// The exit chunk's JSON payload → the derived code +/// (`{"type":"exit","code":N}`). Anything else on `STREAM_CTRL_OUT` +/// has no contract meaning — and an exit chunk that cannot parse has +/// no fidelity input — both map to `None` (no report; the plain +/// close posture). +fn exit_chunk_code(payload: &[u8]) -> Option { + ControlMessage::from_slice(payload) + .ok() + .and_then(|msg| match msg { + ControlMessage::Exit { code } => Some(code), + _ => None, + }) +} + +/// The per-channel bridge surface — handler state while a session row +/// serves (§the control rows). The handler's `window_change_request` +/// / `signal` hooks reach the spawned bridge **through this struct** +/// (the requests arrive on the packet loop after the serving task +/// owns the wire): the bounded control-surface send half, plus the +/// per-channel cancel signal `channel_close` fires. Packet-loop only +/// (single-writer; no lock). +pub(crate) struct BridgeSurface { + /// The control-surface send half — the same bounded mpsc the + /// serving task's stdin pump feeds. + pub(crate) sender: mpsc::Sender, + /// The per-channel cancel signal — `channel_close` fires it. The + /// serving task interprets the fire *or* the half's drop as the + /// cancel. + pub(crate) cancel: watch::Sender, +} + +impl BridgeSurface { + /// Forwards one control row into the bridge — `try_send`, never + /// awaited on the packet loop (§the control rows); a full or + /// closed surface drops the event. + pub(crate) fn forward_control(&self, message: &ControlMessage) -> bool { + let Ok(payload) = message.to_json() else { + return false; + }; + self.sender + .try_send(Chunk { + stream_type: STREAM_CTRL_IN, + bytes: payload, + }) + .is_ok() + } +} + +/// Builds the pair the dispatch hands into the serving task: the +/// surface the handler stores and its consumed receiver counterpart +/// (the bridge's framed-input mpsc) plus the cancel watch halves. +/// The surface's `sender` clone is the stdin pump's producer inside +/// the serving path. +pub(crate) fn build_surface() -> (BridgeSurface, mpsc::Receiver, watch::Receiver) { + let (sender, receiver) = mpsc::channel(CONTROL_SURFACE_BOUND); + let (cancel, cancel_rx) = watch::channel(false); + (BridgeSurface { sender, cancel }, receiver, cancel_rx) +} + +/// Fires the cancel watch (the `channel_close` hook's action). +pub(crate) fn fire_cancel(cancel: &watch::Sender) { + let _ = cancel.send(true); +} + +/// The connection-teardown signal: fired by the handler's `Drop` +/// (the russh session ended, however it ended). Subscribe before the +/// serving task spawns; the receiver also reads the sender's drop as +/// the cancel. +pub(crate) struct Teardown { + sender: watch::Sender, +} + +impl Teardown { + pub(crate) fn new() -> Self { + Self { + sender: watch::Sender::new(false), + } + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.sender.subscribe() + } + + pub(crate) fn fire(&mut self) { + let _ = self.sender.send(true); + } +} + +/// Reads the SSH half of the channel and feeds stdin chunks — the +/// SSH `data` → `Chunk::stdin` adapter (§the translation bridge). +/// On EOF or a read error it sends **the zero-length stdin-chunk +/// sentinel** (the EOF translation decision: identical in effect to +/// alktty's `ControlMessage::Eof` — the backend's stdin shuts while +/// the session keeps pumping) and ends. +async fn stdin_pump( + ssh_read: impl tokio::io::AsyncRead + Unpin + 'static, + chunks: mpsc::Sender, +) { + let mut read_half = ssh_read; + let mut buf = vec![0u8; STDIN_PUMP_BUF]; + loop { + match read_half.read(&mut buf).await { + Ok(0) => { + let _ = chunks + .send(Chunk { + stream_type: STREAM_STDIN, + bytes: Bytes::new(), + }) + .await; + return; + } + Ok(n) => { + let chunk = Chunk { + stream_type: STREAM_STDIN, + bytes: Bytes::copy_from_slice(&buf[..n]), + }; + if chunks.send(chunk).await.is_err() { + return; + } + } + Err(_) => { + let _ = chunks + .send(Chunk { + stream_type: STREAM_STDIN, + bytes: Bytes::new(), + }) + .await; + return; + } + } + } +} + +/// Consumes the framed-input mpsc and writes alktty chunks into the +/// session's `client_recv` duplex half — the framing task. Ends after +/// the EOF sentinel is written (nothing rides the data path past +/// SSH EOF), on a mpsc close (every sender gone — the cancelled or +/// completed session), or on a framing failure. +async fn framing_task(mut chunks: mpsc::Receiver, writer: tokio::io::DuplexStream) { + let mut writer = ChunkWriter::new(writer); + while let Some(chunk) = chunks.recv().await { + if writer.write_chunk(&chunk).await.is_err() { + return; + } + if chunk.stream_type == STREAM_STDIN && chunk.bytes.is_empty() { + return; + } + } +} + +/// The exit report + close + lifecycle event, in the ADR-006 +/// decision-4 order: **status before close, from the spawned task**. +/// A failed send means the session is gone — the lifecycle's one +/// `warn` records it (the report that could not land). +async fn report_exit( + handle: &Handle, + row: &'static str, + exec_route: Option<&'static str>, + channel_id: ChannelId, + channel_number: u32, + report: ExitReport, +) { + let error = match report { + ExitReport::Status(code) => handle.exit_status_request(channel_id, code).await.err(), + ExitReport::Signal(name) => handle + .exit_signal_request( + channel_id, + sig_from_name(name), + false, + String::new(), + "en".to_owned(), + ) + .await + .err(), + }; + let error = match error { + Some(error) => Some(error), + None => handle.close(channel_id).await.err(), + }; + match error { + Some(error) => observe::serving_path_failed(row, channel_number, &error, exec_route), + None => observe::serving_path_ended(row, channel_number, exec_route), + } +} + +/// Fails the request and closes the channel from the serving path — +/// the serving-path decline shape ([`crate::serve`]'s mechanics) with +/// the lifecycle events and the given reason. Used for `no-identity`. +async fn fail_and_close( + handle: &Handle, + row: &'static str, + exec_route: Option<&'static str>, + channel_id: ChannelId, + channel_number: u32, + reason: &'static str, +) { + observe::decline_serving(row, reason, channel_number, exec_route); + let error = match handle.channel_failure(channel_id).await { + Ok(()) => handle.close(channel_id).await.err(), + Err(error) => Some(error), + }; + match error { + Some(error) => observe::serving_path_failed(row, channel_number, &error, exec_route), + None => observe::serving_path_ended(row, channel_number, exec_route), + } +} + +/// The spawned session-row serving path (§module docs): the store's +/// taken channel rides the translation bridge into +/// [`alktty::drive_session_pre_negotiated`]; the alktty session task +/// and the SSH→chunks pump are spawned here, and *this* future drives +/// the chunks→SSH direction inline so the exit report is the path's +/// own final act (status before close — ADR-006 decision 4). The +/// channel was taken by the dispatch; the spawned task owns the +/// stream and its report surface. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn serve_tty_row( + row: &'static str, + exec_route: Option<&'static str>, + handle: Handle, + channel: russh::Channel, + request: NegotiateRequest, + identity: Option, + seam: SessionSeam, + stdin_tx: mpsc::Sender, + chunk_rx: mpsc::Receiver, + cancel_rx: watch::Receiver, + teardown_rx: watch::Receiver, +) { + let channel_id = channel.id(); + let channel_number = channel_id.number(); + observe::serving_path_started(row, channel_number, exec_route); + + let Some(identity) = identity else { + fail_and_close( + &handle, + row, + exec_route, + channel_id, + channel_number, + "no-identity", + ) + .await; + return; + }; + + let (ssh_read, mut ssh_write) = tokio::io::split(channel.into_stream()); + let (chunk_in, chunk_in_b) = tokio::io::duplex(BRIDGE_DUPLEX_CAPACITY); + let (chunk_out, chunk_out_b) = tokio::io::duplex(BRIDGE_DUPLEX_CAPACITY); + let mut chunk_out = chunk_out; + + tokio::spawn(framing_task(chunk_rx, chunk_in)); + tokio::spawn(stdin_pump(ssh_read, stdin_tx)); + + let alktty_session = tokio::spawn(alktty::drive_session_pre_negotiated( + chunk_out_b, + chunk_in_b, + request, + seam.backends, + seam.ownership, + Some(identity), + )); + + drive_chunks_to_ssh( + &handle, + row, + exec_route, + channel_id, + channel_number, + &mut ssh_write, + &mut chunk_out, + cancel_rx, + teardown_rx, + alktty_session, + ) + .await; +} + +/// The chunks→SSH direction, driven inline by the serving path +/// (§the translation bridge): data/`extended_data(1)`/exit-chunk +/// translation, the negotiation-error-frame decline class, and the +/// two cancel signals. Each terminal path ends the lifecycle exactly +/// once. +#[allow(clippy::too_many_arguments)] +async fn drive_chunks_to_ssh( + handle: &Handle, + row: &'static str, + exec_route: Option<&'static str>, + channel_id: ChannelId, + channel_number: u32, + ssh_write: &mut (impl tokio::io::AsyncWrite + Unpin), + chunk_out: &mut tokio::io::DuplexStream, + cancel_rx: watch::Receiver, + teardown_rx: watch::Receiver, + alktty_session: tokio::task::JoinHandle<()>, +) { + let mut cancel_rx = cancel_rx; + let mut teardown_rx = teardown_rx; + loop { + tokio::select! { + frame = read_bridge_frame(chunk_out) => { + match frame { + BridgeFrame::Chunk(chunk) => { + match chunk.stream_type { + STREAM_STDOUT => { + if ssh_write.write_all(&chunk.bytes).await.is_err() { + break; + } + } + STREAM_STDERR => { + let _ = handle + .extended_data(channel_id, 1, chunk.bytes.to_vec()) + .await; + } + STREAM_CTRL_OUT => { + match exit_chunk_code(&chunk.bytes).map(derive_exit) { + Some(report) => { + report_exit( + handle, + row, + exec_route, + channel_id, + channel_number, + report, + ) + .await; + } + None => { + let _ = handle.close(channel_id).await; + observe::serving_path_ended( + row, + channel_number, + exec_route, + ); + } + } + return; + } + _ => {} + } + } + BridgeFrame::NegotiationError => { + observe::decline_serving( + row, + "negotiation-failed", + channel_number, + exec_route, + ); + let _ = handle.exit_status_request(channel_id, 255).await; + let _ = handle.close(channel_id).await; + observe::serving_path_ended(row, channel_number, exec_route); + return; + } + BridgeFrame::Closed | BridgeFrame::Break => { + let _ = handle.close(channel_id).await; + observe::serving_path_ended(row, channel_number, exec_route); + return; + } + } + } + _ = cancel_rx.changed() => break, + _ = teardown_rx.changed() => break, + } + } + alktty_session.abort(); + let _ = handle.close(channel_id).await; + observe::serving_path_ended(row, channel_number, exec_route); +} + +#[cfg(test)] +mod tests { + use super::{ExitReport, SIGNAL_NAMES, derive_exit, sig_control_name, sig_from_name}; + use russh::Sig; + + /// - *Contract stamp:* mapping.md §Exit chunk rule 1 (`code ≥ 0` + /// → `exit-status(N)` — the chunk carries the whole truth for + /// clean exits). + #[test] + fn non_negative_codes_map_to_exit_status() { + assert_eq!(derive_exit(0), ExitReport::Status(0)); + assert_eq!(derive_exit(1), ExitReport::Status(1)); + assert_eq!(derive_exit(42), ExitReport::Status(42)); + assert_eq!(derive_exit(255), ExitReport::Status(255)); + } + + /// - *Contract stamp:* mapping.md §Exit chunk rule 2 (negative + /// `−N`, N ≥ 2 → `exit-signal(name)` via the table — the POC-3 + /// §D.2 live evidence: `−15` → `exit-signal("TERM")`). + #[test] + fn negative_signals_map_through_the_table() { + for (signum, name) in SIGNAL_NAMES.iter().skip(1) { + assert_eq!(derive_exit(-(*signum as i32)), ExitReport::Signal(name)); + } + assert_eq!(derive_exit(-15), ExitReport::Signal("TERM")); + assert_eq!(derive_exit(-2), ExitReport::Signal("INT")); + } + + /// - *Contract stamp:* mapping.md §Exit chunk rule 3 (`−1` → + /// `exit-status(255)`, never a signal claim — the sentinel/ + /// SIGHUP collision accepted symmetrically). + #[test] + fn the_sentinel_is_not_a_signal() { + assert_eq!(derive_exit(-1), ExitReport::Status(255)); + } + + /// - *Contract stamp:* mapping.md §Exit chunk rule 6 (unmapped + /// negatives → rule 3 — the mapping never invents a name). + #[test] + fn unmapped_negatives_report_255() { + for code in [i32::MIN, -33, -32, -100, -1_000_000] { + assert_eq!(derive_exit(code), ExitReport::Status(255)); + } + } + + /// - *Contract stamp:* the table bound — every entry is the + /// conventional Unix signal set 1–31, sorted, names unique and + /// uppercase (the vocabulary the derivation table promises). + #[test] + fn the_table_is_the_conventional_unix_set() { + for (n, name) in SIGNAL_NAMES.iter() { + assert!((1..=31).contains(n), "signum {n} outside 1..=31"); + assert!( + name.chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), + "signal names are uppercase-and-digit vocabulary: {name}" + ); + } + let mut nums: Vec = SIGNAL_NAMES.iter().map(|(n, _)| *n).collect(); + nums.sort_unstable(); + nums.dedup(); + assert_eq!(nums.len(), SIGNAL_NAMES.len(), "no duplicate signa"); + assert_eq!(nums.first(), Some(&1)); + assert_eq!(nums.last(), Some(&31)); + } + + /// The name→`Sig` translation: russh's known set rides variant + /// names; everything else rides `Sig::Custom` (the RFC's + /// signal-name encoding). + #[test] + fn sig_from_name_covers_known_and_custom() { + assert!(matches!(sig_from_name("TERM"), Sig::TERM)); + assert!(matches!(sig_from_name("USR1"), Sig::USR1)); + assert!(matches!(sig_from_name("USR2"), Sig::Custom(_))); + assert!(matches!(sig_from_name("TSTP"), Sig::Custom(_))); + assert_eq!(sig_control_name(&Sig::TERM), "TERM"); + assert_eq!(sig_control_name(&Sig::USR1), "USR1"); + assert_eq!(sig_control_name(&Sig::Custom("ABRT".to_owned())), "ABRT"); + } +} diff --git a/tasks/implementation/w3-tty-session.md b/tasks/implementation/w3-tty-session.md index f4b61b6..78ddf8d 100644 --- a/tasks/implementation/w3-tty-session.md +++ b/tasks/implementation/w3-tty-session.md @@ -1,7 +1,7 @@ --- id: w3-tty-session name: Wave 3 — session → alktty (translation bridge + exit derivation) -status: pending +status: completed depends_on: [w3-git-mapping] scope: moderate risk: medium @@ -98,26 +98,26 @@ proved, re-landed as contract: ## Acceptance Criteria -- [ ] pty/shell/pipe-exec rows serve through +- [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 -- [ ] `pty_request` → `TerminalParamsWire` (ttymodes → the reserved +- [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 -- [ ] The bridge translated per row (stdin chunks + EOF shape +- [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) -- [ ] The derivation contract rules 1–6 in code with the signum→name +- [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 -- [ ] The decline matrix per build green through the harness (unset- +- [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 -- [ ] The real-tool pty smoke row (posture decided + recorded) -- [ ] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2 +- [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 @@ -140,4 +140,137 @@ proved, re-landed as contract: ## Notes -> To be filled at implementation time. \ No newline at end of file +### 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 — 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 — 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`)** — 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` — 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) + — plain close. + +- **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 — 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 — + vanilla sshd's ordering). + +- **The derivation contract** (mapping.md rules 1–6, in code + unit + rows + wire rows): `derive_exit` — `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 — + alktty's ADR-056 kill-on-drop contract is the cancelled session's + cleanup — 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 — 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 — the shim's + `resource_id` guarantees 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. \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 41f5b30..0ff2dc1 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -60,6 +60,12 @@ pub mod harness; #[cfg(feature = "git")] pub mod git_doubles; +/// The session-seam doubles (`w3-tty-session`, `session`-gated — the +/// hand-rolled TtyBackend shim + ownership witness; the module's own +/// doc records the decisions: `tests/common/tty_doubles.rs`). +#[cfg(feature = "session")] +pub mod tty_doubles; + use std::collections::HashMap; use alkcall::core::auth::{AuthToken, Identity, IdentityProvider}; diff --git a/tests/common/tty_doubles.rs b/tests/common/tty_doubles.rs new file mode 100644 index 0000000..8457e1a --- /dev/null +++ b/tests/common/tty_doubles.rs @@ -0,0 +1,336 @@ +//! The `session`-seam test doubles (`w3-tty-session`): the alkssh-side +//! **TtyBackend shim double** — alktty's `MockBackend`/`testing.rs` are +//! `pub(crate)` + `cfg(test)` and never importable, so the suite +//! hand-rolls the shim per the `TtyHandle` field shapes: a backend +//! that records the `TtyParams` it was allocated, consumes stdin +//! (recording bytes, the EOF shutdown and the abort kill), emits +//! scripted stdout/stderr chunks and a scripted exit code, and +//! surfaces a control handle recording resizes/signals. Plus the +//! ownership witness double — the identity that reaches +//! `drive_session_pre_negotiated`'s ADR-050 ownership check is the +//! identity ride's live witness (the task's "identity-landing item"). +//! +//! Every stream/future here is test-scripted and instantly-completed +//! or never-completing — no real process, no real PTY (the real-tool +//! smoke composes alktty's own `local` backend instead, dev-dep only). + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use alkcall::core::auth::Identity; +use alkcall::core::ownership::OwnershipProvider; +use alktty::backend::{TtyBackend, TtyControl, TtyError, TtyHandle, TtyParams}; +use async_trait::async_trait; +use bytes::Bytes; +use tokio::io::AsyncWrite; +use tokio_stream::wrappers::ReceiverStream; + +/// The shim's shared observation surface — every row's assertions +/// come off this (`Record TtyParams`, consume stdin/EOF/resizes/ +/// signals, the abort kill witness). +#[derive(Debug, Default)] +pub struct TtyShimObservation { + /// The `TtyParams` of every allocation, in order. + pub allocations: parking_lot::Mutex>, + /// Bytes written to the shim's stdin, concatenated per allocation. + pub stdin: parking_lot::Mutex>, + /// Whether a stdin shutdown (the EOF translation's destination) + /// was observed — one flag per shutdown, but one allocation per + /// row in practice. + pub stdin_eof: parking_lot::Mutex>, + /// Resizes from the control handle (`cols`, `rows`). + pub resizes: parking_lot::Mutex>, + /// Signals from the control handle — the name strings, verbatim. + pub signals: parking_lot::Mutex>, + /// The abort kill witness: pushed once per `exit_code` future + /// dropped *unresolved* — alktty's kill-on-drop contract + /// (ADR-056) observed through the shim. + pub kills: Arc>>, +} + +/// The shim's scripted per-allocation behavior — what the shim emits +/// and what exit code it resolves with. +#[derive(Debug, Clone, Default)] +pub struct ShimScript { + /// stdout chunks, in emission order, then end-of-stream. + pub stdout: Vec>, + /// stderr chunks, in emission order, then end-of-stream (the shim + /// carries the stderr stream only when these are non-empty *and* + /// the allocation is pipe-mode). + pub stderr: Vec>, + /// The exit code: `Ok(code)` resolves instantly; `None` never + /// resolves (the cancel row's never-ending session). + pub exit: Option, +} + +/// The TtyBackend shim double — a hand-rolled minimal backend per the +/// `TtyHandle` field shapes (see the module doc). Clone-able (the +/// observation + script are `Arc`s). +#[derive(Clone, Default)] +pub struct TtyShim { + observation: Arc, + script: Arc>, +} + +impl TtyShim { + /// A shim with an empty script (allocates, pumps nothing, resolves + /// exit-status 0 by default). + pub fn new() -> Self { + Self::default() + } + + /// Sets the scripted behavior for subsequent allocations. + pub fn set_script(&self, script: ShimScript) { + *self.script.lock() = script; + } + + /// The observation surface. + pub fn observation(&self) -> &Arc { + &self.observation + } +} + +#[async_trait] +impl TtyBackend for TtyShim { + /// Records the params verbatim and hands back the scripted + /// `TtyHandle`. + async fn allocate(&self, params: &TtyParams) -> Result { + self.observation.allocations.lock().push(params.clone()); + let script = self.script.lock().clone(); + + let stdin = ShimStdin { + observation: Arc::clone(&self.observation), + }; + + let stdout = scripted_stream(&script.stdout); + let stderr = if script.terminal_is_pty(params) { + None + } else { + Some(scripted_stream(&script.stderr)) + }; + + let exit_code: alktty::backend::BoxFuture> = Box::pin(GuardedExit { + inner: ShimExit { code: script.exit }, + resolved: false, + kills: Arc::clone(&self.observation.kills), + }); + + let control = Some(alktty::backend::TtyControlHandle::new(Arc::new( + ShimControl { + observation: Arc::clone(&self.observation), + }, + ))); + + Ok(TtyHandle { + stdin: Box::new(stdin), + stdout, + stderr, + exit_code, + control, + }) + } + + /// The ADR-050 check input — every allocation targets this shim + /// resource, so the ownership witness row (the identity surface) + /// always evaluates. + fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { + Some(("shim", "demo".to_owned())) + } +} + +impl ShimScript { + /// Whether the allocation is PTY-mode (the TtyParams's terminal is + /// not the NegotiateRequest.tty this module speaks — the adapter's + /// `From` conversion is what the seam consumes; + /// the shim sees the converted `TtyParams`). + fn terminal_is_pty(&self, params: &TtyParams) -> bool { + params.terminal.is_some() + } +} + +/// A scripted stream — the chunks in order, then end. Instantly +/// drained (the alktty pumps consume; the exit chunk arrives after — +/// the adapter's exit-chunk-is-last invariant orders everything). +fn scripted_stream(chunks: &[Vec]) -> Pin + Send>> { + let (tx, rx) = tokio::sync::mpsc::channel(chunks.len().max(1) + 1); + for chunk in chunks { + let _ = tx.try_send(Bytes::copy_from_slice(chunk)); + } + drop(tx); + Box::pin(ReceiverStream::new(rx)) +} + +/// The shim's stdin — an `AsyncWrite` recording bytes and the EOF +/// shutdown (`poll_shutdown` = the zero-length sentinel's effect at +/// the backend). +struct ShimStdin { + observation: Arc, +} + +impl AsyncWrite for ShimStdin { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.observation.stdin.lock().extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + self.observation.stdin_eof.lock().push(true); + Poll::Ready(Ok(())) + } +} + +/// The scripted exit-code future: `Some(code)` resolves instantly, +/// `None` pends forever (the cancel row's never-ending session). +struct ShimExit { + code: Option, +} + +impl Future for ShimExit { + type Output = Result; + + fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll { + match self.code { + Some(code) => Poll::Ready(Ok(code)), + None => Poll::Pending, + } + } +} + +/// Wrap [`ShimExit`] with alktty's ADR-056 kill-on-drop contract +/// witness: an unresolved drop records the kill; a resolved drop (the +/// future completed) must not. +struct GuardedExit { + inner: ShimExit, + resolved: bool, + kills: Arc>>, +} + +impl Unpin for GuardedExit {} + +impl Future for GuardedExit { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let me = self.get_mut(); + let out = Pin::new(&mut me.inner).poll(cx); + if matches!(&out, Poll::Ready(Ok(_))) { + me.resolved = true; + } + out + } +} + +impl Drop for GuardedExit { + fn drop(&mut self) { + if !self.resolved { + self.kills.lock().push(true); + } + } +} + +/// The shim's control surface — records resizes and signals verbatim. +struct ShimControl { + observation: Arc, +} + +impl TtyControl for ShimControl { + fn resize(&self, cols: u16, rows: u16, _pixel_width: u16, _pixel_height: u16) { + self.observation.resizes.lock().push((cols, rows)); + } + + fn signal(&self, name: &str) { + self.observation.signals.lock().push(name.to_owned()); + } +} + +/// A shim-backed `SessionSeam` in the `session`-seam shape the +/// adapters take — `backends` map holds the shim under `"shim"`, the +/// selector names it, ownership unset (the ADR-050 check skips). +pub fn tty_seam(shim: &Arc) -> alkssh::SessionSeam { + let mut backends = HashMap::new(); + backends.insert("shim".to_owned(), Arc::clone(shim) as Arc); + alkssh::SessionSeam { + backends: Arc::new(backends), + backend: "shim".to_owned(), + ownership: None, + } +} + +/// The ownership witness double ([`alkcall::core::ownership:: +/// OwnershipProvider`]): records every identity evaluation and rules +/// by its configured verdict (the deny shape produces the negotiation +/// error frame on the wire — the bridge's `negotiation-failed` +/// decline). +#[derive(Debug)] +pub struct MemOwnership { + pub owns: bool, + /// The identity ids that reached `owns` — the identity ride's + /// live witness. + pub ids: parking_lot::Mutex>, +} + +impl MemOwnership { + pub fn new(owns: bool) -> Self { + Self { + owns, + ids: parking_lot::Mutex::new(Vec::new()), + } + } +} + +impl OwnershipProvider for MemOwnership { + fn owns( + &self, + identity: &Identity, + _resource_type: &str, + _resource_id: &str, + _action: &str, + ) -> bool { + self.ids.lock().push(identity.id.clone()); + self.owns + } + + fn owned_resources(&self, _identity: &Identity, _resource_type: &str) -> Vec { + Vec::new() + } + + fn owns_any(&self, _identity: &Identity, _resource_type: &str) -> bool { + self.owns + } +} + +/// A shim-backed seam with the ownership provider present — the +/// witness rows' builder. +pub fn tty_seam_with_ownership( + shim: &Arc, + ownership: Arc, +) -> alkssh::SessionSeam { + let mut backends = HashMap::new(); + backends.insert("shim".to_owned(), Arc::clone(shim) as Arc); + alkssh::SessionSeam { + backends: Arc::new(backends), + backend: "shim".to_owned(), + ownership: Some(ownership), + } +} + +/// A seam whose selector names no backend in its own map — the +/// `unknown_backend` negotiation error frame, driven from config. +pub fn misconfigured_seam() -> alkssh::SessionSeam { + alkssh::SessionSeam { + backends: Arc::new(HashMap::new()), + backend: "absent".to_owned(), + ownership: None, + } +} diff --git a/tests/dispatch_core.rs b/tests/dispatch_core.rs index af5123d..453a640 100644 --- a/tests/dispatch_core.rs +++ b/tests/dispatch_core.rs @@ -394,8 +394,10 @@ mod git_build { // --------------------------------------------------------------------------- // session-only build: declines git-parseable exec (the git parse is -// its gate) and serves its rows only to the seam stubs; declines -// tunnel opens (mapping.md §Decline matrix — session row). +// its gate); with no seam/config this build's session rows decline at +// their config gates (the unset-declines contract, serving-path +// shape: `w3-tty-session`); declines tunnel opens (mapping.md +// §Decline matrix — session row). // --------------------------------------------------------------------------- #[cfg(all(feature = "session", not(feature = "git"), not(feature = "tunnel")))] @@ -438,11 +440,13 @@ mod session_build { session.unmount().await; } - /// - *Contract stamp:* ADR-006 point 3 (parse-only: non-git exec - /// routes to the session pipe row — the seam stub declines at - /// the family-seam boundary: failure then close). + /// - *Contract stamp:* OQ-08 + the recorded wire shape + /// (`w3-tty-session`: `exec_wrapper_argv` unset → the pipe row + /// declines — the serving-path shape, failure then close, never + /// a fallback). The row's serving surface (everything + /// configured) is `tests/tty_mapping.rs`'s. #[tokio::test] - async fn plain_exec_routes_to_the_pipe_row_stub() { + async fn plain_exec_declines_wrapper_unconfigured_in_a_session_build() { let session = authenticated().await; let mut channel = open_session_channel(&session.client) .await @@ -450,15 +454,16 @@ mod session_build { request_exec(&channel, "uptime") .await .expect("the exec request rides the wire"); - expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-wrapper pipe row").await; session.unmount().await; } - /// - *Contract stamp:* mapping.md §Channel-type rows - /// (`session` → `pty_request` + `shell_request` is a `session` - /// row; the stub decline is the seam shape). + /// - *Contract stamp:* mapping.md §Channel-type rows + OQ-08 (the + /// `session` feature alone configures nothing: `session_seam` + /// unset → the pty/shell rows decline `seam-unconfigured`, the + /// serving-path shape this build pins). #[tokio::test] - async fn pty_and_shell_route_to_their_row_stubs() { + async fn pty_and_shell_decline_seam_unconfigured_in_a_session_build() { let session = authenticated().await; let mut channel = open_session_channel(&session.client) @@ -467,7 +472,7 @@ mod session_build { request_pty(&channel) .await .expect("the pty request rides the wire"); - expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-seam pty row").await; let mut channel = open_session_channel(&session.client) .await @@ -475,7 +480,7 @@ mod session_build { request_shell(&channel) .await .expect("the shell request rides the wire"); - expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-seam shell row").await; session.unmount().await; } @@ -609,10 +614,10 @@ mod tunnel_build { // --------------------------------------------------------------------------- // All features: the routing decisions hold with the full graph on — -// the session rows and the tunnel rows are still their seam stubs -// (w3-tty-session / w3-tunnel-direct material), and the git row -// declines at its config gate this binary builds no seam for (the -// serving rows ride tests/git_mapping.rs). +// the git row is still its config-driven decline in this binary (the +// serving rows ride tests/git_mapping.rs), and the session rows are +// still their config-driven declines (the serving rows ride +// tests/tty_mapping.rs). // --------------------------------------------------------------------------- #[cfg(all(feature = "git", feature = "session", feature = "tunnel"))] @@ -638,11 +643,12 @@ mod all_features { session.unmount().await; } - /// - *Contract stamp:* mapping.md §Session-channel dispatch - /// (non-git exec routes to the session pipe row in the full - /// build). + /// - *Contract stamp:* OQ-08 + the recorded wire shape (the + /// unset-wrapper pipe row declines — the serving-path shape; + /// `w3-tty-session`). The serving surface rides + /// `tests/tty_mapping.rs`. #[tokio::test] - async fn plain_exec_routes_to_the_pipe_row_stub_with_all_features() { + async fn plain_exec_declines_wrapper_unconfigured_with_all_features() { let session = authenticated().await; let mut channel = open_session_channel(&session.client) .await @@ -650,14 +656,16 @@ mod all_features { request_exec(&channel, "uptime") .await .expect("the exec request rides the wire"); - expect_failure_then_close(&mut channel, "the pipe row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-wrapper pipe row").await; session.unmount().await; } - /// - *Contract stamp:* mapping.md §Channel-type rows (the - /// pty/shell rows route within the session channel). + /// - *Contract stamp:* mapping.md §Channel-type rows + OQ-08 (the + /// unset-seam pty/shell declines with the full graph on — the + /// feature alone configures nothing; the serving surface rides + /// `tests/tty_mapping.rs`). #[tokio::test] - async fn pty_and_shell_route_to_their_stubs_with_all_features() { + async fn pty_and_shell_decline_seam_unconfigured_with_all_features() { let session = authenticated().await; let mut channel = open_session_channel(&session.client) @@ -666,7 +674,7 @@ mod all_features { request_pty(&channel) .await .expect("the pty request rides the wire"); - expect_failure_then_close(&mut channel, "the pty row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-seam pty row").await; let mut channel = open_session_channel(&session.client) .await @@ -674,7 +682,7 @@ mod all_features { request_shell(&channel) .await .expect("the shell request rides the wire"); - expect_failure_then_close(&mut channel, "the shell row serves its seam stub").await; + expect_failure_then_close(&mut channel, "the unset-seam shell row").await; session.unmount().await; } diff --git a/tests/observe.rs b/tests/observe.rs index a2fb8c6..f7cc589 100644 --- a/tests/observe.rs +++ b/tests/observe.rs @@ -552,8 +552,10 @@ mod git_build { fixture("alpha").fingerprint, identity("observe-cap", &["ssh"]), ); - let mut limits = Limits::default(); - limits.max_concurrent_channels = 0; + let limits = Limits { + max_concurrent_channels: 0, + ..Limits::default() + }; let adapter = alkssh::SshAdapter::new(common::harness::serve_config( provider, &[fixture("bravo")], @@ -591,11 +593,13 @@ mod session_build { use super::*; use common::harness::{open_session_channel, request_exec, request_pty}; - /// - *Contract stamp:* ADR-006 decision 4 (the pty serving path is - /// spawned; the lifecycle rides the task — what the wave-3 tty - /// mapping's real path inherits). + /// - *Contract stamp:* ADR-006 decision 4 (the pty serving decline + /// is spawned; the lifecycle rides the task) + OQ-08 (the + /// unset-seam decline: feature on, seam unset — the config- + /// driven class the real paths inherit, + /// `w3-tty-session`). #[tokio::test] - async fn pty_seam_stub_declines_with_the_full_serving_lifecycle() { + async fn pty_declines_seam_unconfigured_with_the_full_serving_lifecycle() { let session = authenticated("session-pty-row").await; let mut channel = open_session_channel(&session.client) .await @@ -606,7 +610,7 @@ mod session_build { let decline = await_event(&[ ("row", "pty_request"), ("decline_class", "serving"), - ("reason", "seam-stub"), + ("reason", "seam-unconfigured"), ]) .await; assert_decline(&decline, &[("row", "pty_request")]); @@ -636,8 +640,12 @@ mod session_build { session.unmount().await; } + /// - *Contract stamp:* OQ-08 + the event vocabulary (`w3-tty- + /// session`: the unset-field pipe decline — + /// `wrapper-unconfigured`, the serving-path class from a + /// spawned task). #[tokio::test] - async fn pipe_seam_stub_declines_with_the_full_serving_lifecycle() { + async fn pipe_declines_wrapper_unconfigured_with_the_full_serving_lifecycle() { let session = authenticated("session-pipe-row").await; let mut channel = open_session_channel(&session.client) .await @@ -650,7 +658,7 @@ mod session_build { let decline = await_event(&[ ("row", "exec_request"), ("decline_class", "serving"), - ("reason", "seam-stub"), + ("reason", "wrapper-unconfigured"), ("exec_route", "pipe"), ]) .await; diff --git a/tests/tty_mapping.rs b/tests/tty_mapping.rs new file mode 100644 index 0000000..f5b0ce2 --- /dev/null +++ b/tests/tty_mapping.rs @@ -0,0 +1,966 @@ +//! The `w3-tty-session` contract rows — the session branch of the +//! spine served for real: pty/shell/pipe-exec through the published +//! alktty seam (`drive_session_pre_negotiated`) over the translation +//! bridge, the locally-built negotiation, the control rows, the exit +//! derivation contract (mapping.md rules 1–6), and the +//! negotiation-error decline class — all observed through the +//! loopback harness against the hand-rolled TtyBackend shim +//! (`tests/common/tty_doubles.rs`). The decline-matrix rows *per +//! feature build* stay in `tests/dispatch_core.rs`; this binary adds +//! the session rows' serving surface, the unset-field declines (which +//! need a configured seam to isolate), and the real-tool pty smoke +//! (feature-gated — in a `session`-less build the binary compiles +//! empty, per the crates' compile-time feature isolation). + +#![allow(dead_code)] + +mod common; + +#[cfg(feature = "session")] +mod serving { + use std::path::PathBuf; + use std::sync::Arc; + + use alktty::backend::TerminalParams; + use russh::client::Msg; + use russh::{Channel, ChannelMsg, Sig}; + + use super::common::harness::{ + LoopbackSession, authenticate_publickey, drive, next_message, open_session_channel, + request_exec, request_pty, request_shell, serve_config as harness_serve_config, + }; + use super::common::tty_doubles as td; + use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity}; + use alkssh::{Limits, ServeConfig, SshAdapter}; + + /// The observation surface on the wire — the terminal-observation + /// enum these rows drive (russh-verbatim shapes, drained in order + /// until the deadline). + #[derive(Debug)] + pub(super) enum Observed { + Data(Vec), + Stderr(Vec), + ExitStatus(u32), + ExitSignal { signal_name: Sig, core_dumped: bool }, + Close, + Failure, + } + + /// The next observation with a deadline — None past 10 s (nothing + /// else can arrive; a row hanging past the deadline is its bug). + pub(super) async fn next_observation(channel: &mut Channel) -> Option { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match next_message(channel).await { + Some(ChannelMsg::Data { data }) => return Some(Observed::Data(data.to_vec())), + Some(ChannelMsg::ExtendedData { data, ext: 1 }) => { + return Some(Observed::Stderr(data.to_vec())); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + return Some(Observed::ExitStatus(exit_status)); + } + Some(ChannelMsg::ExitSignal { + signal_name, + core_dumped, + .. + }) => { + return Some(Observed::ExitSignal { + signal_name, + core_dumped, + }); + } + Some(ChannelMsg::Close) => return Some(Observed::Close), + Some(ChannelMsg::Failure) => return Some(Observed::Failure), + Some(_) => continue, + None => return None, + } + } + }) + .await + .ok() + .flatten() + } + + /// Drains observations until the terminal outcome (status or + /// signal) — collecting the data rides for the ride rows, and + /// asserting the close follows after (the status-before-close + /// order). + pub(super) async fn collect_terminal( + channel: &mut Channel, + ) -> (Vec>, Vec>, Observed) { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + loop { + match next_observation(channel).await { + Some(Observed::Data(data)) => stdout.push(data), + Some(Observed::Stderr(data)) => stderr.push(data), + Some(Observed::ExitStatus(code)) => { + let terminal = Observed::ExitStatus(code); + assert_observed_close_after(channel).await; + return (stdout, stderr, terminal); + } + Some(Observed::ExitSignal { + signal_name, + core_dumped, + }) => { + let terminal = Observed::ExitSignal { + signal_name, + core_dumped, + }; + assert_observed_close_after(channel).await; + return (stdout, stderr, terminal); + } + other => panic!("expected the terminal outcome, got {other:?}"), + } + } + } + + /// The status-before-close contract's second half: a close must + /// follow the terminal observation. + pub(super) async fn assert_observed_close_after(channel: &mut Channel) { + match next_observation(channel).await { + Some(Observed::Close) => {} + other => panic!("expected the channel close after the terminal outcome, got {other:?}"), + } + } + + /// Waits for one Success reply (want-reply requests) — with a + /// deadline; a Failure or a silent close is the panic, the rows + /// drive Success paths. + pub(super) async fn expect_success(channel: &mut Channel, context: &str) { + if tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match next_message(channel).await { + Some(ChannelMsg::Success) => return true, + Some(ChannelMsg::Failure) => panic!("{context}: unexpected request failure"), + Some(_) => continue, + None => panic!("{context}: the channel closed before the Success reply"), + } + } + }) + .await + .is_err() + { + panic!("{context}: the Success reply never arrived"); + } + } + + /// The shim-allocation poll: the first recorded `TtyParams`, once + /// the bridge's session actually allocated. + pub(super) async fn await_allocation(shim: &Arc) -> alktty::backend::TtyParams { + for _ in 0..200 { + if let Some(params) = shim.observation().allocations.lock().first() { + return params.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + panic!("the shim never allocated (no session reached the seam)"); + } + + /// The shim-resize poll. + pub(super) async fn await_resize(shim: &Arc) -> (u16, u16) { + for _ in 0..200 { + if let Some(resize) = shim.observation().resizes.lock().last() { + return *resize; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + panic!("no resize reached the shim's control"); + } + + /// The shim-signal poll. + pub(super) async fn await_signal(shim: &Arc) -> String { + for _ in 0..200 { + if let Some(name) = shim.observation().signals.lock().last() { + return name.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + panic!("no signal reached the shim's control"); + } + + /// Builds the session rows' serve-config — everything set (the + /// shim seam, the shell argv, the exec wrapper, session cwd/env), + /// overridable per row through the mutator. The identity resolves + /// from the `alpha` fixture into `ssh-rider` (the witness id). + pub(super) fn session_config( + provider: Arc, + mutate: impl FnOnce(&mut ServeConfig), + ) -> ServeConfig { + let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default()); + serve.shell_argv = Some(vec!["/bin/none".to_owned(), "-login".to_owned()]); + serve.exec_wrapper_argv = Some(vec!["/bin/wrap".to_owned(), "-x".to_owned()]); + serve.session_cwd = Some(PathBuf::from("/srv/session")); + serve + .session_env + .insert("TEST_SESSION_ENV".to_owned(), "rides".to_owned()); + mutate(&mut serve); + serve + } + + /// Mounts an authenticated loopback session over the given config + /// — the rows' driver (fixture `alpha` → `ssh-rider`). + pub(super) async fn session_with(mutate: impl FnOnce(&mut ServeConfig)) -> LoopbackSession { + let provider = Arc::new(MemIdentityProvider::new()); + provider.register( + fixture("alpha").fingerprint, + identity("ssh-rider", &["ssh"]), + ); + let adapter = SshAdapter::new(session_config(provider, mutate)) + .await + .expect("the session build's adapter constructs"); + let policy = Arc::new(MemHostKeyPolicy::with_default(true)); + let mut session = drive(adapter, Arc::clone(&policy), Limits::default()) + .await + .expect("the loopback mounts"); + let result = + authenticate_publickey(&mut session.client, "ssh-rider", fixture("alpha")).await; + assert!( + result.success(), + "the fixture identity authenticates before every session row" + ); + session + } + + /// A shim-backed seam — the rows' default (no ownership provider). + pub(super) fn shim_seam(shim: &Arc) -> impl FnOnce(&mut ServeConfig) { + let seam = td::tty_seam(shim); + move |serve: &mut ServeConfig| serve.session_seam = Some(seam) + } + + /// Opens a session channel and sends the pty+shell request pair — + /// replaced per-row below (the rows drive their own sequences). + + #[tokio::test] + async fn pty_shell_serves_the_negotiated_shell_row_through_the_bridge() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![b"shell-out".to_vec()], + stderr: vec![], + exit: Some(0), + }); + let session = session_with(shim_seam(&shim)).await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel).await.expect("the pty succeeded"); + expect_success(&mut channel, "the pty request").await; + request_shell(&channel).await.expect("the shell routed"); + expect_success(&mut channel, "the shell request").await; + let (stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + assert_eq!( + stdout.concat(), + b"shell-out".to_vec(), + "the scripted stdout chunk rode SSH data" + ); + let Observed::ExitStatus(0) = terminal else { + panic!("the exit chunk derived exit-status(0), got {terminal:?}") + }; + + let params = await_allocation(&shim).await; + assert_eq!( + params.cmd, + vec!["/bin/none".to_owned(), "-login".to_owned()], + "the shell argv rode the negotiation verbatim (OQ-08)" + ); + let TerminalParams { + term, + cols, + rows, + pixel_width, + pixel_height, + .. + } = params.terminal.expect("the pty params reached the seam"); + assert_eq!(term.as_deref(), Some("xterm")); + assert_eq!((cols, rows), (80, 24), "the pty dims rode"); + assert_eq!((pixel_width, pixel_height), (0, 0)); + assert_eq!( + params.cwd.as_deref(), + Some(std::path::Path::new("/srv/session")), + "session_cwd rides the negotiation, not the wire" + ); + assert_eq!( + params.env.get("TEST_SESSION_ENV").map(String::as_str), + Some("rides"), + "session_env rides the negotiation, not the wire" + ); + assert!( + params.backend_params.is_empty(), + "no backend selector fields exist on this door" + ); + + session.unmount().await; + } + + #[tokio::test] + async fn pipe_exec_serves_the_wrapper_appended_command_in_pipe_mode() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![b"outs".to_vec()], + stderr: vec![b"errd".to_vec()], + exit: Some(0), + }); + let session = session_with(shim_seam(&shim)).await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_exec(&channel, "uptime") + .await + .expect("the exec routed"); + expect_success(&mut channel, "the routed exec").await; + let (stdout, stderr, terminal) = collect_terminal(&mut channel).await; + assert_eq!(stdout.concat(), b"outs".to_vec(), "stdout rode SSH data"); + assert_eq!( + stderr.concat(), + b"errd".to_vec(), + "stderr rode SSH extended_data(1)" + ); + let Observed::ExitStatus(0) = terminal else { + panic!("the exit chunk derived exit-status(0), got {terminal:?}") + }; + + let params = await_allocation(&shim).await; + assert_eq!( + params.cmd, + vec!["/bin/wrap".to_owned(), "-x".to_owned(), "uptime".to_owned()], + "the wire command appended to the wrapper argv — never naively split" + ); + assert!( + params.terminal.is_none(), + "no pty preceded: the exec row serves pipe mode" + ); + + session.unmount().await; + } + + #[tokio::test] + async fn pty_then_exec_serves_pty_mode_with_the_stored_params() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: Some(0), + }); + let session = session_with(shim_seam(&shim)).await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel).await.expect("the pty succeeded"); + expect_success(&mut channel, "the pty request").await; + request_exec(&channel, "echo done").await.expect("routed"); + expect_success(&mut channel, "the routed exec").await; + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + let Observed::ExitStatus(0) = terminal else { + panic!("exit-status(0), got {terminal:?}") + }; + + let params = await_allocation(&shim).await; + assert_eq!( + params.cmd, + vec![ + "/bin/wrap".to_owned(), + "-x".to_owned(), + "echo done".to_owned() + ] + ); + let TerminalParams { cols, rows, .. } = + params.terminal.expect("pty-then-exec serves PTY mode"); + assert_eq!( + (cols, rows), + (80, 24), + "`ssh -t host \"cmd\"` serves PTY-mode" + ); + + session.unmount().await; + } + + #[tokio::test] + async fn shell_without_a_pty_serves_pipe_mode() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: Some(0), + }); + let session = session_with(shim_seam(&shim)).await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_shell(&channel).await.expect("the shell routed"); + expect_success(&mut channel, "the shell request").await; + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + let Observed::ExitStatus(0) = terminal else { + panic!("exit-status(0), got {terminal:?}") + }; + + let params = await_allocation(&shim).await; + assert!( + params.terminal.is_none(), + "the decided row: a shell without a preceding pty serves pipe-mode" + ); + + session.unmount().await; + } + + #[tokio::test] + async fn stdin_bytes_and_the_eof_sentinel_reach_the_backend() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: None, + }); + let session = session_with(shim_seam(&shim)).await; + + let channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_shell(&channel).await.expect("the shell routed"); + let _ = await_allocation(&shim).await; + + channel + .data_bytes(&b"typed data"[..]) + .await + .expect("the stdin ride"); + let data_reached = |shim: &Arc| { + let log = shim.observation().stdin.lock().clone(); + log == b"typed data".to_vec() + }; + for _ in 0..200 { + if data_reached(&shim) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + data_reached(&shim), + "SSH data → Chunk::stdin reached the backend verbatim" + ); + + channel.eof().await.expect("the channel EOF rides the wire"); + for _ in 0..200 { + if !shim.observation().stdin_eof.lock().is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + !shim.observation().stdin_eof.lock().is_empty(), + "the SSH EOF translated to the zero-length stdin-chunk sentinel — \ + the backend's stdin shutdown was observed" + ); + + // The session never resolves its exit (script exit = None); + // the disconnect is the connection-level cancel: the serving + // task aborts the alktty session — the kill-on-drop witness + // recorded, nothing fabricated on the wire. + session.unmount().await; + for _ in 0..100 { + if !shim.observation().kills.lock().is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + !shim.observation().kills.lock().is_empty(), + "the cancellation aborted the never-resolving session — \ + alktty's kill-on-drop contract fired" + ); + } + + #[tokio::test] + async fn window_change_reaches_the_bridge_and_preserve_resize_updates_the_stored_params() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: None, + }); + let session = session_with(shim_seam(&shim)).await; + + // Pre-serve resize: after the pty is stored, before the shell + // request — the stored params resize (the final size wins). + let channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel).await.expect("the pty succeeded"); + channel + .window_change(120, 40, 0, 0) + .await + .expect("the window-change rides (fire-and-forget)"); + request_shell(&channel).await.expect("the shell routed"); + let params = await_allocation(&shim).await; + let TerminalParams { cols, rows, .. } = + params.terminal.expect("the stored pty served the session"); + assert_eq!((cols, rows), (120, 40), "the pre-serve resize won"); + + // Serving resize: the bridge forwards through the bounded + // control surface. + channel + .window_change(200, 50, 0, 0) + .await + .expect("the window-change rides"); + let (resized_cols, resized_rows) = await_resize(&shim).await; + assert_eq!( + (resized_cols, resized_rows), + (200, 50), + "`window_change_request` → `ControlMessage::Resize` CTRL_IN" + ); + + session.unmount().await; + } + + #[tokio::test] + async fn signal_request_forwards_the_name_verbatim_to_the_backend() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: None, + }); + let session = session_with(shim_seam(&shim)).await; + + let channel = open_session_channel(&session.client) + .await + .expect("the session channel opens"); + request_pty(&channel).await.expect("the pty succeeded"); + request_shell(&channel).await.expect("the shell routed"); + let _ = await_allocation(&shim).await; + + channel + .signal(Sig::TERM) + .await + .expect("the signal rides (fire-and-forget)"); + let name = await_signal(&shim).await; + assert_eq!(name, "TERM", "accepted verbatim — no alkssh-side filter"); + + session.unmount().await; + } + + // ------------------------------------------------------------------ + // The exit derivation contract (mapping.md rules 1–6), on the wire. + // ------------------------------------------------------------------ + + /// - *Contract stamp:* mapping.md §Exit chunk rules 1, 2, 6 (code + /// ≥ 0 → `exit-status(N)`; negative `−N`, N ≥ 2 → + /// `exit-signal(name)` via the table; POC-3 §D.2's `−15` → + /// `TERM` live evidence) + rules 3 (the `−1` sentinel → 255, + /// never a signal claim) — each rule's wire row. `core_dumped` + /// is never asserted (the bool stays false). + #[tokio::test] + async fn exit_chunks_derive_by_the_contract_rules() { + for exit_code in [0, 1, -15, -2, -33, -1] { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![b"e".to_vec()], + exit: Some(exit_code), + }); + let session = session_with(shim_seam(&shim)).await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "noop").await.expect("routed"); + expect_success(&mut channel, "the routed exec").await; + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + match (exit_code, &terminal) { + (code, Observed::ExitStatus(status)) if code >= 0 && *status == code as u32 => {} + ( + -15, + Observed::ExitSignal { + signal_name, + core_dumped, + }, + ) => { + assert!(matches!(signal_name, Sig::TERM), "rule 2: −15 → TERM"); + assert!( + !core_dumped, + "core_dumped is never asserted — the bool stays false" + ); + } + ( + -2, + Observed::ExitSignal { + signal_name, + core_dumped, + }, + ) => { + assert!(matches!(signal_name, Sig::INT), "rule 2: −2 → INT"); + assert!(!core_dumped); + } + (-33, Observed::ExitStatus(255)) => {} + (-1, Observed::ExitStatus(255)) => {} + _ => panic!( + "exit chunk {exit_code}: expected the derived contract shape, got {terminal:?}" + ), + } + session.unmount().await; + } + } + + // ------------------------------------------------------------------ + // The negotiation-error decline class (the bridge's serving-path + // decline: the session never started → exit-status(255) + close). + // ------------------------------------------------------------------ + + /// - *Contract stamp:* mapping.md §the seams + the task's + /// negotiate-then-serve decision (the alktty error frame → + /// `negotiation-failed`, `exit-status(255)`, close — the one + /// could-not-start claim this door makes: nothing ran). + #[tokio::test] + async fn unknown_backend_answers_255_then_close() { + let session = session_with(|serve: &mut ServeConfig| { + serve.session_seam = Some(td::misconfigured_seam()); + }) + .await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "uptime").await.expect("routed"); + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + let Observed::ExitStatus(255) = terminal else { + panic!("could-not-start reports exit-status(255), got {terminal:?}") + }; + session.unmount().await; + } + + #[tokio::test] + async fn ownership_denial_answers_255_then_close_and_witnesses_the_identity() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: None, + }); + let ownership = Arc::new(td::MemOwnership::new(false)); + let witness = Arc::clone(&ownership); + let session = session_with(|serve: &mut ServeConfig| { + serve.session_seam = Some(td::tty_seam_with_ownership(&shim, Arc::clone(&ownership))); + }) + .await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "uptime").await.expect("routed"); + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + let Observed::ExitStatus(255) = terminal else { + panic!("the denied ownership is could-not-start (255), got {terminal:?}") + }; + let ids = witness.ids.lock().clone(); + assert_eq!( + ids, + vec!["ssh-rider".to_owned()], + "the resolved identity rode to drive_session_pre_negotiated — \ + the parked wave-2 identity-landing item asserted live" + ); + session.unmount().await; + } + + #[tokio::test] + async fn the_identity_rides_to_the_seam_on_a_serving_row() { + let shim = Arc::new(td::TtyShim::new()); + shim.set_script(td::ShimScript { + stdout: vec![], + stderr: vec![], + exit: Some(0), + }); + let ownership = Arc::new(td::MemOwnership::new(true)); + let witness = Arc::clone(&ownership); + let session = session_with(move |serve: &mut ServeConfig| { + serve.session_seam = Some(td::tty_seam_with_ownership(&shim, ownership)); + }) + .await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "noop").await.expect("routed"); + let (_stdout, _stderr, terminal) = collect_terminal(&mut channel).await; + let Observed::ExitStatus(0) = terminal else { + panic!("exit-status(0), got {terminal:?}") + }; + let ids = witness.ids.lock().clone(); + assert!( + ids.iter().any(|id| id == "ssh-rider"), + "the identity reached the seam's ownership check on the serving path" + ); + session.unmount().await; + } +} + +#[cfg(feature = "session")] +mod unset_declines { + use std::sync::Arc; + + use super::common::harness::{ + LoopbackSession, authenticate_publickey, drive, next_message, open_session_channel, + request_exec, request_pty, request_shell, serve_config as harness_serve_config, + }; + use super::common::tty_doubles as td; + use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity}; + use alkssh::{Limits, ServeConfig, SshAdapter}; + use russh::client::Msg; + use russh::{Channel, ChannelMsg}; + + /// An authenticated loopback over a mutated config — the + /// unset-field rows' driver. + async fn session_with(mutate: impl FnOnce(&mut ServeConfig)) -> LoopbackSession { + let provider = Arc::new(MemIdentityProvider::new()); + provider.register( + fixture("alpha").fingerprint, + identity("unset-rider", &["ssh"]), + ); + let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default()); + serve.exec_wrapper_argv = Some(vec!["/bin/wrap".to_owned()]); + serve.shell_argv = Some(vec!["/bin/none".to_owned()]); + mutate(&mut serve); + let adapter = SshAdapter::new(serve) + .await + .expect("the adapter constructs"); + let policy = Arc::new(MemHostKeyPolicy::with_default(true)); + let mut session = drive(adapter, Arc::clone(&policy), Limits::default()) + .await + .expect("the loopback mounts"); + let result = + authenticate_publickey(&mut session.client, "unset-rider", fixture("alpha")).await; + assert!(result.success()); + session + } + + async fn expect_failure_then_close(channel: &mut Channel, context: &str) { + match next_message(channel).await { + Some(ChannelMsg::Failure) => {} + other => panic!("{context}: expected failure, got {other:?}"), + } + loop { + match next_message(channel).await { + Some(ChannelMsg::Close) => return, + None => panic!("{context}: expected the close after the failure"), + Some(_) => continue, + } + } + } + + /// - *Contract stamp:* OQ-08 (`shell_argv` unset → `shell_request` + /// declines — never a fallback) + the recorded wire shape (the + /// serving-path decline: failure then close, from a spawned + /// task). + #[tokio::test] + async fn shell_argv_unset_declines_the_shell_row() { + let shim = Arc::new(td::TtyShim::new()); + let shim_for_seam = Arc::clone(&shim); + let session = session_with(move |serve: &mut ServeConfig| { + serve.shell_argv = None; + serve.session_seam = Some(td::tty_seam(&shim_for_seam)); + }) + .await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_shell(&channel).await.expect("rides the wire"); + expect_failure_then_close(&mut channel, "the unset shell row").await; + assert!( + shim.observation().allocations.lock().is_empty(), + "a declined row never allocates (decline-first, alloc-never)" + ); + session.unmount().await; + } + + /// - *Contract stamp:* OQ-08 (`exec_wrapper_argv` unset → the + /// pipe row declines) + the recorded wire shape. + #[tokio::test] + async fn exec_wrapper_unset_declines_the_pipe_row() { + let shim = Arc::new(td::TtyShim::new()); + let shim_for_seam = Arc::clone(&shim); + let session = session_with(move |serve: &mut ServeConfig| { + serve.exec_wrapper_argv = None; + serve.session_seam = Some(td::tty_seam(&shim_for_seam)); + }) + .await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "uptime").await.expect("rides"); + expect_failure_then_close(&mut channel, "the unset wrapper row").await; + assert!( + shim.observation().allocations.lock().is_empty(), + "a declined row never allocates" + ); + session.unmount().await; + } + + /// - *Contract stamp:* the decline matrix's config-driven note + /// (`session_seam` unset → the session rows decline even though + /// the `session` feature is on) + the recorded wire shape; the + /// pty row declines too (a doomed allocation is declined first, + /// alloc never). + #[tokio::test] + async fn seam_unset_declines_every_session_row() { + let session = session_with(|_serve: &mut ServeConfig| {}).await; + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_pty(&channel).await.expect("rides the wire"); + expect_failure_then_close(&mut channel, "the unset-seam pty row").await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_shell(&channel).await.expect("rides the wire"); + expect_failure_then_close(&mut channel, "the unset-seam shell row").await; + + let mut channel = open_session_channel(&session.client) + .await + .expect("the channel opens"); + request_exec(&channel, "uptime").await.expect("rides"); + expect_failure_then_close(&mut channel, "the unset-seam pipe row").await; + session.unmount().await; + } +} + +#[cfg(all(unix, feature = "session"))] +mod real_tool { + //! The real-tool smoke (`w3-tty-session`): one OpenSSH pty + //! session (`ssh -tt`, a real PTY via a dev-dep alktty `local` + //! composition — the posture decided: the session rows' smoke + //! composes the REAL backend, not the shim, because a plain shim + //! row cannot observe what vanilla OpenSSH actually sends; POC-3 + //! stage D proved the same composition) through a TCP-mounted + //! adapter. Posture (recorded): requires `ssh` and `sh` in the + //! environment; when absent it is **skipped with a printed + //! reason** (skipped-with-reason at most, never silently green). + //! The smoke owns the pty lifecycle rows only (data rides, clean + //! exit → exit-status before close; the `stty size`-grade + //! fidelity matrix is wave 5's). + + use std::collections::HashMap; + use std::process::Command; + use std::sync::Arc; + + use alkssh::SessionSeam; + use alkssh::{Limits, SshAdapter}; + use alktty::LocalTtyBackend; + use alktty::backend::TtyBackend; + + use super::common::harness::serve_config as harness_serve_config; + use super::common::{MemIdentityProvider, fixture, identity}; + + /// The `local` composition for the smoke — alktty's own backend in + /// the seam map (dev-dep unification: the `local` feature rides the + /// test graph only). + fn local_seam() -> SessionSeam { + let mut backends = HashMap::new(); + backends.insert( + "local".to_owned(), + Arc::new(LocalTtyBackend::new()) as Arc, + ); + SessionSeam { + backends: Arc::new(backends), + backend: "local".to_owned(), + ownership: None, + } + } + + #[tokio::test] + async fn real_openssh_pty_session_smoke() { + if Command::new("ssh").arg("-V").output().is_err() { + eprintln!("SKIPPED (with reason): real-tool pty smoke — ssh is unavailable"); + return; + } + if Command::new("sh").arg("-c").arg("exit 0").output().is_err() { + eprintln!("SKIPPED (with reason): real-tool pty smoke — sh is unavailable"); + return; + } + + let work = std::env::temp_dir().join(format!("alkssh-tty-smoke-{}", std::process::id())); + std::fs::create_dir_all(&work).expect("the smoke workdir creates"); + let key_path = work.join("id_ed25519"); + std::fs::write(&key_path, fixture("alpha").pem).expect("the fixture key writes"); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .expect("the key file perms set"); + + let provider = Arc::new(MemIdentityProvider::new()); + provider.register( + fixture("alpha").fingerprint, + identity("smoke-rider", &["ssh"]), + ); + let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default()); + serve.shell_argv = Some(vec!["/bin/sh".to_owned(), "-i".to_owned()]); + serve.session_seam = Some(local_seam()); + let adapter = Arc::new( + SshAdapter::new(serve) + .await + .expect("the smoke adapter constructs"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the smoke listener binds"); + let port = listener.local_addr().expect("local addr").port(); + let accept_task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let adapter = Arc::clone(&adapter); + tokio::spawn(async move { + let _ = adapter.handle(stream).await; + }); + } + }); + + let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), async { + let mut child = tokio::process::Command::new("ssh") + .args(["-tt", "-p", &port.to_string(), "-i"]) + .arg(&key_path) + .args([ + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + ]) + .arg("smoke-rider@127.0.0.1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("ssh spawns"); + { + use tokio::io::AsyncWriteExt; + let mut stdin = child.stdin.take().expect("the child stdin is piped"); + stdin + .write_all(b"echo alkssh-tty-smoke; exit\n") + .await + .expect("the smoke stdin writes"); + stdin.flush().await.expect("the smoke stdin flushes"); + } + let output = child.wait_with_output().await.expect("ssh completes"); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + ) + }) + .await; + + accept_task.abort(); + let _ = std::fs::remove_dir_all(&work); + + let (success, stdout) = match outcome { + Ok((success, stdout)) => (success, stdout), + Err(_) => panic!("the real ssh pty smoke exceeded its 30 s deadline"), + }; + assert!( + success, + "vanilla OpenSSH's pty+shell session through the door exits 0 \ + (the exit chunk derived exit-status(0) before close) — stdout: {stdout:?}" + ); + } +}