--- id: w2-loopback-harness name: Wave 2 — the loopback harness (in-process client/server test pair) status: completed depends_on: [w2-auth-bridge, w1-test-doubles] scope: moderate risk: medium impact: phase level: implementation --- ## Description Land the in-process client/server test pair every later contract test runs through — the decline matrix, every mapping's contract tests (wave 3), and the client-direction smokes all drive the crate through this harness. In-memory doubles from wave 1; real-tool smokes ride the mapping waves (wave 3+) and the e2e wave (wave 5). - **Server side** — the `SshAdapter` from `w2-server-mount` over an in-process transport. The transport choice (tokio duplex pair vs 127.0.0.1:0 TCP loopback) is pinned here — both work with `run_stream`/russh's client connect; decide on determinism and portability and record the choice. - **Client side** — a russh client driven directly (the wave-4 dial surface does not exist yet; the harness builds the russh client config from the doubles): in-memory key from the ed25519 fixtures (`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive test posture (`with_default(true)` — the accept-all posture exists only as a test choice, never a crate built-in), the keepalive posture from `Limits`. - **Harness helpers** — the vocabulary the contract tests speak: authenticate with a fixture key (and observe auth failure shapes), open a channel type (and observe the open-failure reason), send a session request (and observe success/failure), await exit-status / close. Helpers stay thin — the russh client API is the vocabulary; the harness removes boilerplate, not semantics. - **Convention** — lives with the wave-1 doubles under `tests/common/` (or a sibling harness module — same convention, decide and record); documented as *the* contract-test vehicle for the crate. The harness's first consumer is its own acceptance test: a full in-process connect → authenticate (fixture key through `MemIdentityProvider`) → authenticated-session observation, proving the wave-1 doubles work live through the wave-2 stack. ## Acceptance Criteria - [x] In-process server/client pair connects and authenticates end to end (fixture key → `MemIdentityProvider` → authenticated session) with no TCP port dependency (or the TCP choice recorded, if taken) - [x] Harness helpers cover: auth (success + failure observation), channel open (success + open-failure reason), session requests (success/failure), exit-status/close observation - [x] The transport choice and module placement are recorded - [x] The harness is documented as the contract-test vehicle; the wave-1 doubles are exercised live through it - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/plans/implementation.md §Wave 2 (the loopback-harness bullet) - docs/architecture/mapping.md §Decline matrix (the rows the harness will drive) - docs/research/poc-1-auth-bridge.md (the decline-observability facts the helpers must be able to observe) - tests/common/mod.rs (the wave-1 doubles this harness drives) ## Notes - **Transport decision (recorded, in `tests/common/harness.rs` module docs):** tokio duplex pair (64 KiB/direction) + russh's `connect_stream`. russh's real codec/kex/transport run over the pair verbatim; the one gap versus real sockets (no OS-socket semantics) is covered more cheaply by the real-tool smokes of wave 3+ — TCP loopback's listener lifecycle and timing flakiness buy nothing the harness needs. Deterministic, no port, no collision. - **Placement decision (recorded, same docs):** `tests/common/harness.rs`, a sibling module of the wave-1 doubles reached with the same `mod common;` convention — test surface, never a public API export. Unlike the doubles it is a *vehicle* convention: later waves call `common::harness::drive` rather than re-deriving a mount/connect pair. - **russh 0.65 behavior surfaced by the harness (a dispatch-core input):** each un-implemented server `channel_open_*` hook drops its `ChannelOpenHandle` → the client observes `ChannelOpenFailure::AdministrativelyProhibited` — the inherited fail-closed decline on the current skeleton stack. The harness's authenticated-session row matches the reason generically (`Error::ChannelOpenFailure(_)`), so `w2-dispatch-decline-core`'s reason rows cannot break the vehicle. - **Scope posture:** the harness is a test-vehicle task — no `src/` change. A russh-server behavior note rides the row docs only. ## Summary Landed the loopback harness (`tests/common/harness.rs`, wired through `tests/common/mod.rs`'s `pub mod harness`), with both pinned decisions recorded in its module docs: **tokio duplex pair** as the in-process transport (`russh::client::connect_stream` against the mounted `SshAdapter::handle`; recorded over TCP loopback for determinism and no-port portability) and **sibling-of-the-doubles placement** under `tests/common/` as the crate's contract-test vehicle convention. Contents: - **`HarnessClientHandler`** — the russh client handler bridging the `MemHostKeyPolicy` double into `check_server_key`, computing the decision input ([`PresentedHostKey`]) exactly as the wave-4 dial wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT` (the no-port marker `0`), the fingerprint on the crate's single fingerprint path. - **`client_config`** — the keepalive/wall-clock posture from `Limits` (interval/max verbatim, inactivity always set). - **Server-side assemblers** — `serve_config` / `adapter` from the wave-1 doubles. - **`drive` → `LoopbackSession`** — the mount-spawn + connect pair, with clean-disconnect `unmount`. - **Auth helpers** — `authenticate_publickey` (fixture plumbing removed, `AuthResult` verbatim) and `remaining_methods` (the advertisement half of the failure shape). - **Channel helpers** — `open_session_channel` (open-failure reason observed in the `Err`), `request_exec` / `request_shell` / `request_subsystem`, and the message observations: `next_message`, `await_exit_status`, `await_close`. Acceptance proof (`tests/loopback_harness.rs`, 6 rows, every row a `Contract stamp:`): full in-process connect → fixture-key auth through `MemIdentityProvider` → authenticated-session observation (host-key decision log carrying `bravo`'s fingerprint under the loopback labels; a channel open answered with an observed decline reason); the auth failure shape with `{publickey}` remaining; the fail-closed host-key verdict as a clean dial decline; the pinned-rule trust-anchor case; and the `Limits` → client-config posture row. Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`, `MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody rows (fixture keys are unencrypted by contract). The session-request and exit-status/close helpers are exercised by construction this wave — channel opens don't confirm on the decline-only skeleton; their live rows arrive with `w2-dispatch-decline-core`. Verification: `cargo test` (81 passed across 6 binaries), `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` — all green.