Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
Renames the written-usage rule into practice across the contract surface:
docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files,
taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the
.opencode/agents/ prompts. Every replacement is a sense-preserving
rewrite (colons, semicolons, commas, parentheses, sentence splits, varied),
never a mechanical swap; no spaced hyphens were substituted. Enum fields,
frontmatter, tables, wire data, and cited identifiers survive verbatim;
en dashes inside numeric ranges keep their meaning.

Verification: taskgraph validate (43 tasks), repo grep clean (single
exempt hit: the verification command's own search pattern in AGENTS.md),
cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
2026-11-04 20:28:00 +00:00

182 lines
9.5 KiB
Markdown

---
id: w2-server-mount
name: "Wave 2: SshAdapter::handle mount + russh Config construction"
status: completed
depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model]
scope: moderate
risk: medium
impact: project
level: implementation
---
## Description
Land the door frame: the `SshAdapter` type, the ADR-001 mount point,
and the russh server `Config` construction from the wave-1
`ServeConfig`. This is the substrate every mapping composes into; the
auth bridge and dispatch fill the Handler this task skeletons.
- **`SshAdapter`**: holds the `ServeConfig`; construction
(`SshAdapter::new`) loads the host keys decrypt-at-construction via
the injected `HostKeySource` (ADR-005 decision 6): a locked /
absent / decryption-failed / unknown-version load means **no server
is constructed**, and the error surfaces as a distinct adapter
construction error wrapping the `KeySourceError` variant (never a
half-configured adapter, never a keyless server that fails later).
- **russh `Config` construction** (ADR-002 surface notes), built from
the serve-config: `methods` = `{publickey}` **exactly**
(`MethodSet` from `[MethodKind::Publickey]`: honest advertisement,
ADR-004 decision 4); `keys` from the loaded host-key set;
`window_size` / `maximum_packet_size` / `channel_buffer_size` /
`max_auth_attempts` mapped verbatim from `Limits`;
`inactivity_timeout` mapped **always-`Some`** (the bounded-resources
posture; the crate never constructs a session with no wall clock);
russh's rekey `Limits` stay russh-enforced at their RFC-4253
defaults (recorded in the wave-1 config notes; keep that
recording). `server_id` stays russh's default posture unless the
task finds a family reason otherwise (decide and record).
- **The mount**: `SshAdapter::handle(stream)` with a generic stream
in,
the full russh server stack per stream via `russh::server::run_stream`
(edge termination only, with no ALPN code and no relay leg; the
`alk/ssh` pin is a ledger entry, ADR-001, and nothing here speaks
it). The exact
return shape (awaited `RunningSession`, an internal spawn + join
handle, …) is decided here against the spawn-and-return posture
(ADR-006 decision 4) and the assembly's mounting needs: decide and
record; it is semver-visible once consumers exist.
- **The Handler skeleton**: the per-connection `russh::server::Handler`
impl with the adapter's `type Error` (`From<russh::Error>` via
thiserror, the wave-2-shaped error surface the wave-1 error-model
note reserved); every hook left at its fail-closed russh default
(decline/reject). Later wave-2 tasks fill the auth hooks
(`w2-auth-bridge`) and the channel/request dispatch
(`w2-dispatch-decline-core`).
No mapping seams, no feature-gated serving (wave 3); no client dial
(wave 4). The feature gates stay untouched, since nothing consumes
them yet (the dispatch task is their first `cfg` consumer).
## Acceptance Criteria
- [x] `SshAdapter::new(ServeConfig)` loads host keys
decrypt-at-construction; locked / absent / decryption-failure /
unknown-version → `Err` with the `KeySourceError` variant
preserved, no adapter constructed
- [x] russh `Config` built from the serve-config: `methods` is exactly
`{publickey}`; host keys ride `Config::keys`; the `Limits` knobs
map one-for-one with `inactivity_timeout` always `Some`
- [x] `SshAdapter::handle` mounts the full russh server stack per
generic stream (`run_stream`); no ALPN/relay code exists
- [x] The Handler skeleton compiles with an adapter error type
(`From<russh::Error>`); every hook at its fail-closed default
- [x] Tests: honest advertisement (methods exactly `{publickey}`); the
knob mapping; construction failure postures (locked vault → no
server, absent host keys → no server)
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` pass
## References
- docs/architecture/decisions/001-alkssh-alpn-pin.md (the mount-point
contract: `SshAdapter::handle`, raw vanilla-SSH stream in)
- docs/architecture/decisions/002-russh-pin.md (the Config surface
notes; honest advertisement)
- docs/architecture/decisions/004-auth-dispositions.md (decision 4:
`methods = {publickey}`)
- docs/architecture/decisions/005-at-rest-key-custody.md
(decrypt-at-construction; locked → no server)
- docs/architecture/overview.md §Bounded-resources policy
- docs/plans/implementation.md §Wave 2 (first bullet)
- russh 0.65.0 in-source: `server::Config`, `server::run_stream`,
`auth::MethodSet`
## Notes
Implementation record (decisions made at this task's discretion, the
"decide and record" items):
- **The mount's return shape (`handle` runs to completion)**
(`Result<(), AdapterError>`): `handle` is an async per-stream call
that awaits `run_stream`'s `RunningSession` through the session's
end, matching the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler::
handle` is spawned per connection by the assembly's accept loop;
alkhttp/src/server/adapter.rs spawns it in tests the same way).
ADR-006 decision 4 is satisfied inside russh itself: `run_stream`
spawns the session's packet loop on its own task before returning,
so the serving path is already spawned-and-returned at the
handler-hook level; `handle` does not double-spawn (that would add
a task layer and a join-handle surface without adding posture). The
alternative candidates (returning `RunningSession`, or an internal
spawn + `JoinHandle`) were rejected: the first leaks a generic
russh type into the public signature (plus the `Handler` type it
parameterizes); the second hides the failure boundary behind a
detached task while still not matching the accept-loop shape any
better. The chosen shape's signature is `Result<(), AdapterError>`
`, the smallest honest surface; it is semver-visible and recorded
here.
- **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the
`Config::default()` posture). No family reason found to change it:
the vanilla-ssh wire is vanilla; the version string is
interoperability information, not a family advertisement, and
POC-3's real-tool evidence rode that posture. Changing it would be
policy the crate doesn't own; recorded for any future pin bump.
- **The ed25519-only posture holds by construction + the auth bridge,
not by a construction check.** The loaded key set is the
advertisement; ADR-002 decision 4's "an assembly registers ed25519
fingerprints only" governs what the assembly sources. Flagged for
the wave-2 gate: if host-key algorithm filtering ever becomes
wire-contract territory (currently it is not, as the key source
contract is silent), it would be an ADR amendment, not this task.
- **Empty key set = `KeySourceError::Absent` even from a custom
source**: `SshAdapter::new` additionally refuses an `Ok(vec![])`
load as `Construction(Absent)`, since an empty `Config::keys` would
serve
no host key honestly, and the `key_source.rs` wave-1 contract
already documents the empty set as the absent shape.
- **Keepalive stays russh-default on the server direction**
(`None` / 3): the `Limits` keepalive knobs are documented
client-direction posture (reverse-runner evidence); no server-side
keepalive posture exists and none is invented here.
- **`SessionHandler` is `pub(crate)`**: the skeleton's type never
appears in a public signature (thanks to the run-to-completion
return shape), so the public API this task exposes is exactly
`SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility
when its handler-level tests need it.
- Tests ride the wave-1 fixture keys via `include_str!` (the wave-1
src-unit convention) and the real `VaultHostKeySource` through an
in-memory vault for the decrypt-at-construction rows; one wire smoke
(russh's own client over a tokio duplex through `handle`) proves the
full stack mounts and the skeleton declines auth fail-closed with
exactly the `{publickey}` advertisement visible on the wire.
## Summary
`SshAdapter` is landed as the ADR-001 mount point: `SshAdapter::new`
decrypt-loads the host key set at construction (any load failure or
empty set → `AdapterError::Construction(KeySourceError variant)`, no
adapter) and builds the russh 0.65 server `Config` with honest
advertisement (`methods` exactly `{publickey}`), the loaded keys, the
`Limits` knobs mapped one-for-one, `inactivity_timeout` always
`Some`, and everything else (rekey `Limits`, `auth_rejection_time`,
`server_id`, preferred sets) left verbatim at russh's default posture.
`SshAdapter::handle` is the edge-termination mount: a generic duplex
stream in, `run_stream` per stream, run to completion to
`Result<(), AdapterError>` (no ALPN/relay code; the `alk/ssh` pin
unspoken). The per-connection `SessionHandler` skeleton (pub(crate))
carries the `Arc<ServeConfig>` for the auth-bridge/dispatch tasks and
stands entirely at russh's fail-closed defaults, with
`AdapterError` (`Construction(#[from] KeySourceError)` +
`Wire(#[from] russh::Error)`) as its `type Error`.
Verification: 12 new tests covering advertisement-exactly-publickey,
knob
mapping (tuned + documented-default postures), decrypt-at-construction
key-set parity, construction failure matrix (locked / absent / empty /
tampered / wrong-vault / unknown-version / decode, variant preserved),
EOF-on-mount, and a wire smoke (russh client over duplex completing
KEX against the fixture host key and observing the fail-closed
decline with the exact `{publickey}` method list). `cargo test`,
`cargo test --all-features`, `cargo clippy --all-targets -- -D
warnings`, `cargo clippy --all-features --all-targets -- -D warnings`,
`cargo fmt --check`, and `cargo doc --no-deps` all green.