feat(session): land the session mapping — the translation bridge + the exit derivation (w3-tty-session)

The biggest single-row surface of the spine: pty/shell/pipe-exec serve
through the published alktty seam (drive_session_pre_negotiated) over a
translation bridge, re-landing POC-3 stage D as contract:

- src/tty_map.rs (session feature): the bridge (SSH data → Chunk::stdin
  through a bounded mpsc + framing task; SSH EOF → the zero-length
  stdin sentinel — verified against alktty's pump semantics; stdout →
  data; stderr → extended_data(1); exit chunk → the derivation
  contract), the locally-built negotiation (OQ-08: shell argv verbatim,
  wrapper ++ [cmd], cwd/env session-side, ttymodes → the reserved {}
  modes JSON), and the spawned serving path.
- serve_tty_row: driven chunks→SSH inline so the exit report is the
  path's final act (status before close, ADR-006 decision 4); two
  cancel signals (per-channel watch fired by channel_close, connection
  teardown fired by SessionHandler::Drop) abort the alktty session
  task — the ADR-056 kill-on-drop contract is the cancelled session's
  cleanup.
- Derivation contract rules 1–6 in derive_exit + the crate-local
  signum→name table: −N → exit-signal(name), −1 → exit-status(255),
  core_dumped never, unmapped negatives → 255, the PTY-path rule-5
  fidelity limit documented; wire rows pin the shapes.
- Negotiation-error frames (alktty's 0x00-length-prefixed error
  framing) → negotiation-failed serving decline + exit-status(255) +
  close — the one could-not-start claim this door makes (nothing ran).
- Control rows: window_change/signal → the per-channel BridgeSurface
  (bounded mpsc, try_send, never awaited on the packet loop); signal
  names verbatim (no decline shape — fire-and-forget); pre-serve
  resizes update the stored pty params (final size wins).
- Unset-declines (OQ-08) keep the git row's serving-path shape
  (failure then close, spawned): shell-unconfigured /
  wrapper-unconfigured / seam-unconfigured; pipe-less shell SERVES
  pipe-mode (the recorded vanilla-sshd posture); env_request decline
  unchanged (OQ-09).
- tests: the hand-rolled TtyBackend shim + ownership witness doubles;
  serving rows through the harness, the unset-field decline matrix,
  derivation rows on the wire, negotiation-failure rows, the identity
  ride asserted live at the seam, the kill witness; the real-tool
  smoke — OpenSSH -tt against a real PTY via dev-dep alktty local
  (test graph only), skip-with-reason posture. Wave-2 stub rows
  migrated to the new decline reasons (dispatch_core, observe).

Verification: cargo test (113) / --features session (149) / git (142) /
tunnel (115) / --all-features (161) — all green; cargo clippy
--all-targets -- -D warnings ×5 feature configs; cargo fmt --check;
cargo doc --no-deps ×3 builds clean; taskgraph validate.
This commit is contained in:
glm-5.3-flash committed 2026-10-28 20:29:59 +00:00
1 parent 7aac2d11a2
commit b1e569d1a8
13 files changed
+3012 -149

No files matched your search

Generated
+156 -11
View File
@@ -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"
+19 -1
View File
@@ -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"
+5 -5
View File
@@ -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) |
+416 -83
View File
@@ -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<ChannelId, Channel<Msg>>,
/// 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<ChannelId, alktty::negotiation::TerminalParamsWire>,
/// 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<ChannelId, tty_map::BridgeSurface>,
/// 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<Channel<Msg>> {
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,9 +736,13 @@ 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 {
pipe_row_dispatch(self, channel, session, command.to_owned());
return Ok(());
}
#[cfg(not(feature = "session"))]
let _ = &command;
observe::decline_request(
"exec_request",
"unfeatured",
@@ -656,50 +752,204 @@ impl russh::server::Handler for SessionHandler {
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<Msg>,
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
+2
View File
@@ -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")]
+17 -4
View File
@@ -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<E: std::fmt::Debug>(
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));
+895
View File
@@ -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<TerminalParamsWire>,
cmd: Vec<String>,
cwd: Option<std::path::PathBuf>,
env: std::collections::BTreeMap<String, String>,
) -> 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<i32> {
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<Chunk>,
/// 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<bool>,
}
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<Chunk>, watch::Receiver<bool>) {
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<bool>) {
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<bool>,
}
impl Teardown {
pub(crate) fn new() -> Self {
Self {
sender: watch::Sender::new(false),
}
}
pub(crate) fn subscribe(&self) -> watch::Receiver<bool> {
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<Chunk>,
) {
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<Chunk>, 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<russh::server::Msg>,
request: NegotiateRequest,
identity: Option<Identity>,
seam: SessionSeam,
stdin_tx: mpsc::Sender<Chunk>,
chunk_rx: mpsc::Receiver<Chunk>,
cancel_rx: watch::Receiver<bool>,
teardown_rx: watch::Receiver<bool>,
) {
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<bool>,
teardown_rx: watch::Receiver<bool>,
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<u32> = 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");
}
}
+142 -9
View File
@@ -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.
### 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.
+6
View File
@@ -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};
+336
View File
@@ -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<Vec<TtyParams>>,
/// Bytes written to the shim's stdin, concatenated per allocation.
pub stdin: parking_lot::Mutex<Vec<u8>>,
/// 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<Vec<bool>>,
/// Resizes from the control handle (`cols`, `rows`).
pub resizes: parking_lot::Mutex<Vec<(u16, u16)>>,
/// Signals from the control handle — the name strings, verbatim.
pub signals: parking_lot::Mutex<Vec<String>>,
/// 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<parking_lot::Mutex<Vec<bool>>>,
}
/// 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<Vec<u8>>,
/// 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<Vec<u8>>,
/// The exit code: `Ok(code)` resolves instantly; `None` never
/// resolves (the cancel row's never-ending session).
pub exit: Option<i32>,
}
/// 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<TtyShimObservation>,
script: Arc<parking_lot::Mutex<ShimScript>>,
}
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<TtyShimObservation> {
&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<TtyHandle, TtyError> {
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<Result<i32, TtyError>> = 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<NegotiateRequest>` 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<u8>]) -> Pin<Box<dyn tokio_stream::Stream<Item = Bytes> + 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<TtyShimObservation>,
}
impl AsyncWrite for ShimStdin {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
self.observation.stdin.lock().extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
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<i32>,
}
impl Future for ShimExit {
type Output = Result<i32, TtyError>;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
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<parking_lot::Mutex<Vec<bool>>>,
}
impl Unpin for GuardedExit {}
impl Future for GuardedExit {
type Output = Result<i32, TtyError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
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<TtyShimObservation>,
}
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<TtyShim>) -> alkssh::SessionSeam {
let mut backends = HashMap::new();
backends.insert("shim".to_owned(), Arc::clone(shim) as Arc<dyn TtyBackend>);
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<Vec<String>>,
}
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<String> {
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<TtyShim>,
ownership: Arc<MemOwnership>,
) -> alkssh::SessionSeam {
let mut backends = HashMap::new();
backends.insert("shim".to_owned(), Arc::clone(shim) as Arc<dyn TtyBackend>);
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,
}
}
+35 -27
View File
@@ -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;
}
+17 -9
View File
@@ -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;
+966
View File
@@ -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<u8>),
Stderr(Vec<u8>),
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<Msg>) -> Option<Observed> {
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<Msg>,
) -> (Vec<Vec<u8>>, Vec<Vec<u8>>, 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<Msg>) {
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<Msg>, 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<td::TtyShim>) -> 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<td::TtyShim>) -> (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<td::TtyShim>) -> 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<MemIdentityProvider>,
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<td::TtyShim>) -> 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<td::TtyShim>| {
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<Msg>, 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<dyn TtyBackend>,
);
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:?}"
);
}
}